import { app, BrowserWindow, dialog, Menu, globalShortcut, ipcMain, screen, shell } from 'electron' import { checkDockerStatus } from './dockerStatus' import type { WebContents } from 'electron' import { spawn } from 'child_process' import * as pty from 'node-pty' import type { IPty } from 'node-pty' import { appendFileSync, createWriteStream, existsSync, mkdirSync } from 'fs' import { cp, mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'fs/promises' import { homedir } from 'os' import { basename, dirname, join, relative, resolve } from 'path' import { is } from '@electron-toolkit/utils' import { createHash } from 'crypto' import http from 'http' import https from 'https' let mainWindow: BrowserWindow | null = null let splashWindow: BrowserWindow | null = null const MIN_SPLASH_VISIBLE_MS = 2000 let splashVisibleStartedAt = 0 let splashCloseTimer: NodeJS.Timeout | null = null const terminalProcesses = new Map() const terminalStopTimers = new Map() const intentionalTerminalStops = new Set() const nav2RuntimeObstacleNames = new Set() const remoteSshQueues = new Map>() const remoteDockerStatsRetryAfter = new Map() const remoteGitModuleRoots = new Map() const remoteSshControlDirectory = resolve(homedir(), '.autonomy-studio', 'ssh', 'control') const moduleCacheRoot = resolve(homedir(), '.caches') const artifactCacheRoot = resolve(homedir(), '.cache', 'autonomy-studio', 'artifacts') const studioRuntimeRoot = resolve(homedir(), 'autonomy-studio', 'runtime') const autowareDockerImage = 'ghcr.io/autowarefoundation/autoware-universe:humble-20240301-cuda' const nav2DockerImage = 'future-nav2-gazebo:humble' const autowareRosBridgeImage = 'autoware-ros-bridge' // Keep the bundled bridge image revisioned. Reusing `latest` leaves an old // Docker image in place after an LMS update, even when bridge dependencies or // protocol code changed. const slamRosBridgeImage = 'slam-ros-bridge:20260813-3d-map-v2' const slamSimulationRuntimeImage = 'future-nav2-gazebo:humble' const slamSimulationRuntimeContainerName = 'nav2-slam' const rosBridgePort = 8765 const exampleCodeRoot = resolve(homedir(), '다운로드', 'example-code') function writeRendererDiagnostic(kind: string, message: string): void { try { const logDirectory = app.getPath('logs') mkdirSync(logDirectory, { recursive: true }) appendFileSync( join(logDirectory, 'renderer.log'), `[${new Date().toISOString()}] [${kind}] ${message}\n`, 'utf8', ) } catch { // Diagnostics must never interrupt the LMS window itself. } } async function readAppDiagnostics(): Promise<{ path: string; content: string }> { const logPath = join(app.getPath('logs'), 'renderer.log') try { const content = await readFile(logPath, 'utf8') const recentContent = content.split(/\r?\n/).filter(Boolean).slice(-500).join('\n') return { path: logPath, content: recentContent } } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { return { path: logPath, content: '' } } throw error } } const skippedCacheDirs = new Set(['.git', 'build', 'install', 'log', 'node_modules', 'docker']) const textFilePattern = /\.(c|cc|cpp|cxx|h|hpp|hh|py|xml|urdf|xacro|yaml|yml|json|txt|md|cmake|launch|sh|bash|toml|ini|cfg|rviz)$/i const imageFilePattern = /\.(png|jpe?g|webp|gif|svg)$/i const maxImagePreviewBytes = 10 * 1024 * 1024 const explicitModuleFolders: Record = { 'FD-SIM': '[furive-gui]AW-Rviz_v1.0.0_ws' } const workspaceGitIgnoreLines = [ '', '# Autonomy Studio generated outputs', 'build/', 'install/', 'log/', '__pycache__/', '**/__pycache__/', '*.py[cod]', '.pytest_cache/', '.mypy_cache/', '.cache/', '.autonomy-studio-git.json' ] const workspaceGitPathspec = [ '--', '.', ':(exclude)build/**', ':(exclude)install/**', ':(exclude)log/**', ':(exclude)**/__pycache__/**', ':(exclude)**/*.pyc', ':(exclude)**/*.pyo', ':(exclude)**/*.pyd', ':(exclude).pytest_cache/**', ':(exclude).mypy_cache/**', ':(exclude).cache/**', ':(exclude).autonomy-studio-git.json' ] const studioGitMetadataFile = '.autonomy-studio-git.json' function getAppIconPath(): string | undefined { const candidates = [ join(process.cwd(), 'resources/icon.png'), join(process.resourcesPath, 'icon.png'), join(process.resourcesPath, 'resources/icon.png') ] return candidates.find((candidate) => existsSync(candidate)) } function getBundledResourcePath(...segments: string[]): string | null { const candidates = [ join(process.resourcesPath, ...segments), join(process.resourcesPath, 'resources', ...segments), join(process.cwd(), 'resources', ...segments), join(app.getAppPath(), 'resources', ...segments) ] return candidates.find((candidate) => existsSync(candidate)) ?? null } function getBundledZstdPath(): string | null { if (process.platform !== 'linux') return null const arch = process.arch === 'arm64' ? 'arm64' : process.arch === 'x64' ? 'x64' : null if (!arch) return null return getBundledResourcePath('bin', `linux-${arch}`, 'zstd') } type CachedModuleFiles = { root: string | null absoluteRoot: string | null files: Record folders: string[] } type CachedModuleInfo = { id?: string state: 'running' | 'stopped' | 'uninstalled' sortOrder: number category: string name: string folderName: string sourcePath: string workspacePath: string | null templateRepositoryUrl?: string | null sourceType?: string | null prebuiltArtifacts?: ModulePrebuiltArtifact[] | null dockerImage?: string | null packageName?: string | null launchFile?: string | null runtimeTarget?: 'LOCAL' | 'PLATFORM' | 'SHARED' | null parameters: string[] cpu: number | null memory: number | null runtime: string } type CourseModuleInput = { id?: string sortOrder?: number | null category: string name: string folderName: string templateRepositoryUrl?: string | null sourceType?: string | null prebuiltArtifacts?: ModulePrebuiltArtifact[] | null dockerImage?: string | null packageName?: string | null launchFile?: string | null runtimeTarget?: 'LOCAL' | 'PLATFORM' | 'SHARED' | null } type RosGraphEndpoint = { node: string type: string } type RosGraphTopic = { name: string types: string[] publishers: RosGraphEndpoint[] subscribers: RosGraphEndpoint[] } type RosGraphSnapshot = { topics: RosGraphTopic[] message?: string } type CustomModuleScaffoldInput = { name: string category: string folderName: string dockerImage?: string | null packageName: string launchFile: string } type CustomModuleImportSource = { sourcePath: string packageName: string launchFiles: string[] buildType: 'ament_python' | 'ament_cmake' } type CustomModuleImportInput = CustomModuleScaffoldInput & { sourcePath: string } type ModulePrebuiltArtifact = { type?: string | null url?: string | null fileName?: string | null checksumSha256?: string | null extractPath?: string | null architecture?: string | null required?: boolean | null } type RuntimeImageArtifact = { image?: string | null url?: string | null fileName?: string | null checksumSha256?: string | null required?: boolean | null } const defaultNav2GazeboRuntimeImage: RuntimeImageArtifact = { image: 'future-nav2-gazebo:humble', url: 'https://pub-ca72c40797144462bdb7d200c215d361.r2.dev/studio-artifacts/runtime-images/nav2/gazebo/latest/future-nav2-gazebo-humble.tar.zst', fileName: 'future-nav2-gazebo-humble.tar.zst', checksumSha256: '54230e039c1fa5dc82615cef3828c5acae7b8bea8e921b70cfdc5351d8301644', required: true, } type ModuleInstallOptions = { prebuiltArtifacts?: ModulePrebuiltArtifact[] | null runtimeImages?: RuntimeImageArtifact[] | null } type RemoteWorkspaceTarget = { host: string user?: string | null port?: number | null workspaceRoot?: string | null identityFile?: string | null } type RosNetworkConfig = { enabled?: boolean | null peer?: RemoteWorkspaceTarget | null } type VisualizationStack = 'autoware' | 'nav2' | 'slam' | 'slam3d' type VisualizationBridgeStatus = { ok: boolean running: boolean stack: VisualizationStack | null message?: string } type WorldLayoutObject = { id: string type: 'pillar' | 'wall' | 'box' position: [number, number, number] rotation: [number, number, number] size: [number, number, number] } type WorldLayout = { version: 1 bounds: { width: number depth: number } objects: WorldLayoutObject[] } type SavedMapArtifactName = 'yaml' | 'pgm' | 'database' type SavedMapArtifactPath = { name: SavedMapArtifactName path: string } type SavedMapArtifactDownload = { name: SavedMapArtifactName fileName: string url: string } type PointCloudVoxel = { maxZ: number samples: number } type PointCloudLayoutCache = { source: { fileName: string size: number modifiedAtMs: number } layout: WorldLayout } type PgmImage = { width: number height: number maxValue: number pixels: Uint16Array } type PgmMapMetadata = { resolution: number originX: number originY: number negate: boolean occupiedThreshold: number } type MapCollisionRectangle = { startX: number endX: number startY: number endY: number } // Build the Gazebo collision layer from the real LiDAR's horizontal sensing // band. This rejects floor and ceiling points, then extrudes each occupied // voxel down to the ground for a stable 2D navigation obstacle. const pointCloudMinObstacleHeightMeters = 0.25 const pointCloudMaxObstacleHeightMeters = 1.2 const maxPointCloudCollisionObjects = 1_200 const pointCloudVoxelSizes = [0.16, 0.2, 0.25, 0.32, 0.4, 0.5] const mapCollisionHeightMeters = 1.2 const maxMapCollisionObjects = 900 function defaultWorldLayout(): WorldLayout { return { version: 1, bounds: { width: 10, depth: 10 }, objects: [] } } function normalizeWorldLayout(value: unknown): WorldLayout { if (!value || typeof value !== 'object' || !Array.isArray((value as { objects?: unknown }).objects)) return defaultWorldLayout() const objects = (value as { objects: unknown[] }).objects.flatMap((item): WorldLayoutObject[] => { if (!item || typeof item !== 'object') return [] const object = item as Partial if (!['pillar', 'wall', 'box'].includes(String(object.type))) return [] const tuple = (input: unknown, fallback: [number, number, number]): [number, number, number] => ( Array.isArray(input) && input.length === 3 && input.every((part) => typeof part === 'number' && Number.isFinite(part)) ? [input[0] as number, input[1] as number, input[2] as number] : fallback ) return [{ id: typeof object.id === 'string' && object.id.trim() ? object.id.trim() : `object-${Date.now()}`, type: object.type as WorldLayoutObject['type'], position: tuple(object.position, [0, 0, 0.5]), rotation: tuple(object.rotation, [0, 0, 0]), size: tuple(object.size, [1, 1, 1]) }] }).filter((object) => !['wall-1', 'pillar-1'].includes(object.id)) const boundsInput = (value as { bounds?: unknown }).bounds const bounds = boundsInput && typeof boundsInput === 'object' ? boundsInput as { width?: unknown; depth?: unknown } : {} const size = (input: unknown, fallback: number): number => ( typeof input === 'number' && Number.isFinite(input) ? Math.min(40, Math.max(2, input)) : fallback ) return { version: 1, bounds: { width: size(bounds.width, 10), depth: size(bounds.depth, 10) }, objects } } function isSavedMapCollisionObject(object: WorldLayoutObject): boolean { return /^saved-(?:map-obstacle|3d-map-voxel)-/i.test(object.id) } // Saved-map collision layouts used to be copied into world-layout.json. Keep // that file exclusively for the custom map and discard only those old generated // objects while preserving any user-created objects that may coexist there. function normalizeCustomWorldLayout(value: unknown): WorldLayout { const layout = normalizeWorldLayout(value) const objects = layout.objects.filter((object) => !isSavedMapCollisionObject(object)) return objects.length === layout.objects.length ? layout : { ...layout, objects } } function worldLayoutPath(workspaceRoot?: string | null): string { const root = expandUserPath(workspaceRoot) if (!root) throw new Error('워크스페이스 경로가 설정되어 있지 않습니다.') return resolve(root, 'world-layout.json') } function safeSavedMapFileName(input: string): string { const fileName = basename(input.trim()) if (!fileName || fileName === '.' || fileName === '..' || !/^[A-Za-z0-9._-]+$/.test(fileName)) { throw new Error('저장된 맵 파일명이 올바르지 않습니다.') } return fileName } function savedMapPointCloudLayoutFileName(databaseFileName: string): string { return safeSavedMapFileName(databaseFileName.replace(/\.db$/i, '.gazebo-3d-layout.json')) } function savedMapPointCloudLayoutCacheFileName(databaseFileName: string): string { return safeSavedMapFileName(databaseFileName.replace(/\.db$/i, '.gazebo-3d-layout-cache.json')) } function savedMapCollisionLayoutFileName(yamlFileName: string): string { return safeSavedMapFileName(yamlFileName.replace(/\.yaml$/i, '.gazebo-layout.json')) } async function writeJsonAtomically(path: string, value: unknown): Promise { const tempPath = `${path}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}` await writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, 'utf8') await rename(tempPath, path) } function pgmWhitespace(byte: number | undefined): boolean { return byte === 9 || byte === 10 || byte === 13 || byte === 32 } function readPgmImage(contents: Buffer): PgmImage { let offset = 0 const nextToken = (): string => { while (offset < contents.length) { if (contents[offset] === 35) { while (offset < contents.length && contents[offset] !== 10) offset += 1 } else if (pgmWhitespace(contents[offset])) { offset += 1 } else break } const start = offset while (offset < contents.length && !pgmWhitespace(contents[offset]) && contents[offset] !== 35) offset += 1 return contents.subarray(start, offset).toString('ascii') } const format = nextToken() const width = Number(nextToken()) const height = Number(nextToken()) const maxValue = Number(nextToken()) if (!['P2', 'P5'].includes(format) || !Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0 || width * height > 4_000_000 || !Number.isInteger(maxValue) || maxValue <= 0 || maxValue > 65_535) { throw new Error('저장된 PGM 맵 형식이 올바르지 않습니다.') } const pixels = new Uint16Array(width * height) if (format === 'P2') { for (let index = 0; index < pixels.length; index += 1) { const value = Number(nextToken()) if (!Number.isInteger(value) || value < 0 || value > maxValue) throw new Error('저장된 PGM 맵 픽셀 데이터가 올바르지 않습니다.') pixels[index] = value } return { width, height, maxValue, pixels } } if (!pgmWhitespace(contents[offset])) throw new Error('저장된 PGM 맵 헤더가 올바르지 않습니다.') if (contents[offset] === 13 && contents[offset + 1] === 10) offset += 2 else offset += 1 const bytesPerPixel = maxValue < 256 ? 1 : 2 if (contents.length - offset < pixels.length * bytesPerPixel) throw new Error('저장된 PGM 맵 데이터가 불완전합니다.') for (let index = 0; index < pixels.length; index += 1) { pixels[index] = bytesPerPixel === 1 ? contents[offset + index] : (contents[offset + index * 2] << 8) | contents[offset + index * 2 + 1] } return { width, height, maxValue, pixels } } function readPgmMapMetadata(contents: string): PgmMapMetadata { const scalar = (key: string, fallback: number): number => { const match = contents.match(new RegExp(`^\\s*${key}\\s*:\\s*([^#\\r\\n]+)`, 'mi')) const value = Number(match?.[1]?.trim()) return Number.isFinite(value) ? value : fallback } const origin = contents.match(/^\s*origin\s*:\s*\[\s*([-+\d.eE]+)\s*,\s*([-+\d.eE]+)/mi) const originX = Number(origin?.[1]) const originY = Number(origin?.[2]) const resolution = scalar('resolution', NaN) if (!Number.isFinite(resolution) || resolution <= 0 || !Number.isFinite(originX) || !Number.isFinite(originY)) { throw new Error('저장된 맵 YAML의 해상도 또는 원점 정보가 올바르지 않습니다.') } return { resolution, originX, originY, negate: scalar('negate', 0) !== 0, occupiedThreshold: Math.min(1, Math.max(0, scalar('occupied_thresh', 0.65))) } } function mapCollisionRectangles(image: PgmImage, metadata: PgmMapMetadata, scale: number): MapCollisionRectangle[] { const columns = Math.ceil(image.width / scale) const rows = Math.ceil(image.height / scale) const occupied = (x: number, y: number): boolean => { const endY = Math.min(image.height, y + scale) const endX = Math.min(image.width, x + scale) for (let sourceY = y; sourceY < endY; sourceY += 1) { for (let sourceX = x; sourceX < endX; sourceX += 1) { const value = image.pixels[sourceY * image.width + sourceX] / image.maxValue if ((metadata.negate ? value : 1 - value) >= metadata.occupiedThreshold) return true } } return false } const rectangles: MapCollisionRectangle[] = [] let activeRuns = new Map() for (let row = 0; row < rows; row += 1) { const nextRuns = new Map() let column = 0 while (column < columns) { if (!occupied(column * scale, row * scale)) { column += 1; continue } const startColumn = column while (column < columns && occupied(column * scale, row * scale)) column += 1 const key = `${startColumn}:${column}` const active = activeRuns.get(key) nextRuns.set(key, active ? { ...active, endY: row + 1 } : { startX: startColumn, endX: column, startY: row, endY: row + 1 }) } activeRuns.forEach((rectangle, key) => { if (!nextRuns.has(key)) rectangles.push(rectangle) }) activeRuns = nextRuns } activeRuns.forEach((rectangle) => rectangles.push(rectangle)) return rectangles } function createMapCollisionLayout(yamlContents: string, pgmContents: Buffer): WorldLayout { const image = readPgmImage(pgmContents) const metadata = readPgmMapMetadata(yamlContents) let scale = 1 let rectangles = mapCollisionRectangles(image, metadata, scale) while (rectangles.length > maxMapCollisionObjects && scale < 8) { scale *= 2; rectangles = mapCollisionRectangles(image, metadata, scale) } if (rectangles.length > maxMapCollisionObjects) throw new Error('저장된 맵의 장애물이 너무 많아 시뮬레이션 충돌 환경으로 변환할 수 없습니다.') const objects = rectangles.map((rectangle, index): WorldLayoutObject => { const minX = metadata.originX + rectangle.startX * scale * metadata.resolution const maxX = metadata.originX + Math.min(image.width, rectangle.endX * scale) * metadata.resolution const minY = metadata.originY + (image.height - Math.min(image.height, rectangle.endY * scale)) * metadata.resolution const maxY = metadata.originY + (image.height - rectangle.startY * scale) * metadata.resolution return { id: `saved-map-obstacle-${index + 1}`, type: 'box', position: [(minX + maxX) / 2, (minY + maxY) / 2, mapCollisionHeightMeters / 2], rotation: [0, 0, 0], size: [Math.max(metadata.resolution, maxX - minX), Math.max(metadata.resolution, maxY - minY), mapCollisionHeightMeters] } }) return normalizeWorldLayout({ version: 1, bounds: { width: 40, depth: 40 }, objects }) } async function generateWorkspaceMapCollisionLayout(workspaceRoot: string | null | undefined, yamlPath: string, pgmPath: string): Promise { const root = expandUserPath(workspaceRoot) if (!root) throw new Error('워크스페이스 경로가 설정되어 있지 않습니다.') const yamlFileName = savedMapArtifactFileName('yaml', yamlPath) const pgmFileName = savedMapArtifactFileName('pgm', pgmPath) const mapsDirectory = resolve(root, 'maps') const yamlFilePath = resolve(mapsDirectory, yamlFileName) const pgmFilePath = resolve(mapsDirectory, pgmFileName) if (!yamlFilePath.startsWith(`${mapsDirectory}/`) || !pgmFilePath.startsWith(`${mapsDirectory}/`)) throw new Error('저장된 맵 경로가 올바르지 않습니다.') const layout = createMapCollisionLayout(await readFile(yamlFilePath, 'utf8'), await readFile(pgmFilePath)) await mkdir(mapsDirectory, { recursive: true }) await writeJsonAtomically(resolve(mapsDirectory, savedMapCollisionLayoutFileName(yamlFileName)), layout) return layout } function readExportedPointCloud(plyContents: Buffer): Array<[number, number, number]> { const headerEnd = plyContents.indexOf(Buffer.from('end_header\n')) if (headerEnd < 0 || headerEnd > 32_768) throw new Error('RTAB-Map 포인트 클라우드 헤더가 올바르지 않습니다.') const headerLength = headerEnd + 'end_header\n'.length const header = plyContents.subarray(0, headerLength).toString('ascii') if (!header.includes('format binary_little_endian 1.0')) { throw new Error('RTAB-Map 포인트 클라우드 형식을 읽을 수 없습니다.') } const vertexCount = Number(header.match(/^element vertex\s+(\d+)\s*$/m)?.[1]) // rtabmap-export produces x/y/z followed by five float fields. const stride = 32 if (!Number.isInteger(vertexCount) || vertexCount <= 0 || plyContents.length < headerLength + vertexCount * stride) { throw new Error('RTAB-Map 포인트 클라우드 데이터가 불완전합니다.') } const points: Array<[number, number, number]> = [] for (let index = 0; index < vertexCount; index += 1) { const offset = headerLength + index * stride const x = plyContents.readFloatLE(offset) const y = plyContents.readFloatLE(offset + 4) const z = plyContents.readFloatLE(offset + 8) if (Number.isFinite(x) && Number.isFinite(y) && Number.isFinite(z)) points.push([x, y, z]) } return points } function createPointCloudCollisionLayout(points: Array<[number, number, number]>): WorldLayout { const obstaclePoints = points.filter(([, , z]) => ( z >= pointCloudMinObstacleHeightMeters && z <= pointCloudMaxObstacleHeightMeters )) if (!obstaclePoints.length) throw new Error('3D SLAM 맵에서 Gazebo 장애물로 변환할 포인트를 찾지 못했습니다.') let voxels = new Map() let voxelSize = pointCloudVoxelSizes[pointCloudVoxelSizes.length - 1] for (const candidateSize of pointCloudVoxelSizes) { const nextVoxels = new Map() obstaclePoints.forEach(([x, y, z]) => { const gridX = Math.floor(x / candidateSize) const gridY = Math.floor(y / candidateSize) const key = `${gridX}:${gridY}` const current = nextVoxels.get(key) if (current) { current.maxZ = Math.max(current.maxZ, z) current.samples += 1 } else { nextVoxels.set(key, { maxZ: z, samples: 1 }) } }) voxels = nextVoxels voxelSize = candidateSize if (voxels.size <= maxPointCloudCollisionObjects) break } if (voxels.size > maxPointCloudCollisionObjects) { throw new Error('3D SLAM 맵의 장애물이 너무 많아 Gazebo 충돌 환경으로 변환할 수 없습니다.') } let minX = Infinity let minY = Infinity let maxX = -Infinity let maxY = -Infinity const objects = Array.from(voxels.entries()) .filter(([, voxel]) => voxel.samples >= 2) .map(([key, voxel], index): WorldLayoutObject => { const [gridX, gridY] = key.split(':').map(Number) const centerX = (gridX + 0.5) * voxelSize const centerY = (gridY + 0.5) * voxelSize const floorZ = 0 const ceilingZ = Math.min(pointCloudMaxObstacleHeightMeters, voxel.maxZ + voxelSize / 2) const height = Math.max(voxelSize, ceilingZ) minX = Math.min(minX, centerX - voxelSize / 2) minY = Math.min(minY, centerY - voxelSize / 2) maxX = Math.max(maxX, centerX + voxelSize / 2) maxY = Math.max(maxY, centerY + voxelSize / 2) return { id: `saved-3d-map-voxel-${index + 1}`, type: 'box', position: [centerX, centerY, floorZ + height / 2], rotation: [0, 0, 0], size: [voxelSize, voxelSize, height] } }) if (!objects.length || !Number.isFinite(minX) || !Number.isFinite(minY) || !Number.isFinite(maxX) || !Number.isFinite(maxY)) { throw new Error('3D SLAM 맵의 유효한 장애물을 생성하지 못했습니다.') } const padding = 2 return normalizeWorldLayout({ version: 1, bounds: { width: Math.max(10, maxX - minX + padding * 2), depth: Math.max(10, maxY - minY + padding * 2) }, objects }) } async function ensureSlamRosBridgeImage(): Promise { const exists = await runCommand('docker', ['image', 'inspect', slamRosBridgeImage], undefined, undefined, 10_000) .then(() => true) .catch(() => false) if (!exists) await ensureBridgeImage(slamRosBridgeImage, 'resources/slam-ros-bridge') } async function exportWorkspaceMapPointCloud(workspaceRoot: string, databaseFileName: string): Promise { const exportDirectoryName = `.gazebo-3d-export-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}` const mapsDirectory = resolve(workspaceRoot, 'maps') const exportDirectory = resolve(mapsDirectory, exportDirectoryName) const exportedPly = resolve(exportDirectory, 'gazebo-map_cloud.ply') await mkdir(exportDirectory, { recursive: true }) try { await ensureSlamRosBridgeImage() const userId = typeof process.getuid === 'function' ? process.getuid() : 1000 const groupId = typeof process.getgid === 'function' ? process.getgid() : 1000 await runCommand('docker', [ 'run', '--rm', '--user', `${userId}:${groupId}`, '-v', `${workspaceRoot}:/workspace`, '--entrypoint', 'bash', slamRosBridgeImage, '-lc', [ 'source /opt/ros/humble/setup.bash', `rtabmap-export --cloud --scan --voxel 0.08 --output gazebo-map --output_dir /workspace/maps/${exportDirectoryName} /workspace/maps/${databaseFileName}` ].join(' && ') ], undefined, undefined, 180_000) return await readFile(exportedPly) } finally { await rm(exportDirectory, { recursive: true, force: true }).catch(() => undefined) } } async function generateWorkspacePointCloudCollisionLayout( workspaceRoot: string | null | undefined, databasePath: string, ): Promise { const root = expandUserPath(workspaceRoot) if (!root) throw new Error('워크스페이스 경로가 설정되어 있지 않습니다.') const databaseFileName = savedMapArtifactFileName('database', databasePath) const mapsDirectory = resolve(root, 'maps') const databaseFilePath = resolve(mapsDirectory, databaseFileName) if (!databaseFilePath.startsWith(`${mapsDirectory}/`)) throw new Error('저장된 3D 맵 경로가 올바르지 않습니다.') const layoutPath = resolve(mapsDirectory, savedMapPointCloudLayoutFileName(databaseFileName)) const cachePath = resolve(mapsDirectory, savedMapPointCloudLayoutCacheFileName(databaseFileName)) const sourceStat = await stat(databaseFilePath) try { const cached = JSON.parse(await readFile(cachePath, 'utf8')) as Partial if (cached.source?.fileName === databaseFileName && cached.source.size === sourceStat.size && cached.source.modifiedAtMs === sourceStat.mtimeMs && cached.layout) { const layout = normalizeWorldLayout(cached.layout) return layout } } catch { // Create the cached layout below when it does not exist or is stale. } const layout = createPointCloudCollisionLayout(readExportedPointCloud( await exportWorkspaceMapPointCloud(root, databaseFileName) )) await mkdir(mapsDirectory, { recursive: true }) await writeJsonAtomically(layoutPath, layout) await writeJsonAtomically(cachePath, { source: { fileName: databaseFileName, size: sourceStat.size, modifiedAtMs: sourceStat.mtimeMs }, layout, } satisfies PointCloudLayoutCache) return layout } function savedMapArtifactFileName(name: SavedMapArtifactName, path: string): string { const fileName = safeSavedMapFileName(path) const expectedExtension = name === 'yaml' ? '.yaml' : name === 'pgm' ? '.pgm' : '.db' if (!fileName.endsWith(expectedExtension)) throw new Error('저장된 맵 파일 형식이 올바르지 않습니다.') return fileName } function validateSavedMapDownload(input: SavedMapArtifactDownload): SavedMapArtifactDownload { const fileName = savedMapArtifactFileName(input.name, input.fileName) const parsed = new URL(input.url) if (!['http:', 'https:'].includes(parsed.protocol)) throw new Error('저장된 맵 다운로드 주소가 올바르지 않습니다.') return { ...input, fileName, url: parsed.toString() } } async function readWorkspaceSavedMapArtifacts( workspaceRoot: string | null | undefined, artifacts: SavedMapArtifactPath[], ): Promise> { const root = expandUserPath(workspaceRoot) if (!root) throw new Error('워크스페이스 경로가 설정되어 있지 않습니다.') return Promise.all(artifacts.map(async (artifact) => { const fileName = savedMapArtifactFileName(artifact.name, artifact.path) const filePath = resolve(root, 'maps', fileName) if (!filePath.startsWith(`${resolve(root, 'maps')}/`)) throw new Error('저장된 맵 경로가 올바르지 않습니다.') return { name: artifact.name, fileName, base64: (await readFile(filePath)).toString('base64') } })) } async function syncWorkspaceSavedMapArtifacts( workspaceRoot: string | null | undefined, artifacts: SavedMapArtifactDownload[], ): Promise<{ ok: boolean }> { const root = expandUserPath(workspaceRoot) if (!root) throw new Error('워크스페이스 경로가 설정되어 있지 않습니다.') const mapsDirectory = resolve(root, 'maps') await mkdir(mapsDirectory, { recursive: true }) const probePath = resolve(mapsDirectory, `.autonomy-studio-write-${process.pid}`) try { await writeFile(probePath, '') await rm(probePath, { force: true }) } catch (error) { await rm(probePath, { force: true }).catch(() => undefined) if (!isPermissionError(error)) throw error const image = await resolveWorkspaceCleanupImage() if (!image) throw new Error('저장된 맵 디렉터리에 쓸 권한이 없고 권한 복구용 NAV2 이미지를 찾을 수 없습니다.') const userId = typeof process.getuid === 'function' ? process.getuid() : 1000 const groupId = typeof process.getgid === 'function' ? process.getgid() : 1000 await runCommand('docker', [ 'run', '--rm', '--user', '0:0', '-v', `${root}:/workspace`, '--entrypoint', '/bin/sh', image, '-lc', `mkdir -p /workspace/maps && chown -R ${userId}:${groupId} /workspace/maps`, ], undefined, undefined, 30_000) } await Promise.all(artifacts.map(async (artifact) => { const normalized = validateSavedMapDownload(artifact) const targetPath = resolve(mapsDirectory, normalized.fileName) // SLAM containers can leave an artifact owned by root. Removing it first // lets the download recreate the file with the desktop user's ownership. await rm(targetPath, { force: true }) await downloadArtifact(normalized.url, targetPath) })) return { ok: true } } type LearningVideoInput = { url?: string | null title?: string | null } type EnvironmentProfileConfig = { type?: string target?: 'local' | 'remote-jetson' | string agent?: { url?: string token?: string } runtime?: { containers?: Array<{ name?: string image?: string network?: string privileged?: boolean env?: Record volumes?: string[] command?: string }> steps?: Array<{ container?: string mode?: 'execDetached' delayMs?: number command?: string logFile?: string }> } runtimeImages?: RuntimeImageArtifact[] | null bridge?: { name?: string image?: string buildContext?: string port?: number env?: Record } } type GitChangedFile = { path: string status: 'added' | 'modified' | 'deleted' | 'renamed' | 'untracked' staged: boolean unstaged: boolean } type GitCommitInfo = { hash: string shortHash: string author: string date: string message: string } type GitStatusInfo = { ok: boolean branch: string | null ahead: number behind: number files: GitChangedFile[] selectedFilePath: string | null diff: string commits: GitCommitInfo[] message?: string } type StudioGitSubmission = { id: string submittedAt: string moduleName: string commit: GitCommitInfo } type StudioGitMetadata = { baseCommit?: string submissions?: StudioGitSubmission[] } function toRendererModulePath(moduleName: string, absolutePath: string, rootPath: string): string { return `modules/${moduleName}/${relative(rootPath, absolutePath).split('\\').join('/')}` } function toRendererRootPath(rendererRoot: string, absolutePath: string, rootPath: string): string { const relativePath = relative(rootPath, absolutePath).split('\\').join('/') return relativePath ? `${rendererRoot}/${relativePath}` : rendererRoot } function expandUserPath(inputPath?: string | null): string | null { if (!inputPath?.trim()) return null const trimmedPath = inputPath.trim() if (trimmedPath === '~') return homedir() if (trimmedPath.startsWith('~/')) return resolve(homedir(), trimmedPath.slice(2)) return resolve(trimmedPath) } function parseModuleFolderName(folderName: string): { name: string; category: string } { const categoryMatch = folderName.match(/^\[([^\]]+)\]/) const rawCategory = categoryMatch?.[1]?.replace(/^furive-/, '') ?? 'Module' const withoutPrefix = folderName.replace(/^\[[^\]]+\]/, '') const name = withoutPrefix.replace(/_v[\d.]+_ws$/i, '').replace(/_ws$/i, '') const category = rawCategory .split(/[-_\s]+/) .filter(Boolean) .map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`) .join(' ') return { name, category } } function parseModuleDockerInfo(folderName: string): { containerName: string; name: string; version: string; containerKey: string } | null { const match = folderName.match(/^\[([^\]]+)\](.+)_v([^/]+)_ws$/) if (!match) return null const [, containerName, name, version] = match return { containerName, name, version, containerKey: `${containerName}-${name}_v${version}` } } function isNav2ModuleFolder(folderName: string): boolean { return /^\[lms-nav2-[^\]]+\].+_v[^/]+_ws$/.test(folderName) } function isGazeboNav2ModuleFolder(folderName?: string | null): boolean { return /^\[lms-nav2-gazebo-[^\]]+\].+_v[^/]+_ws$/.test(folderName?.trim() ?? '') } function isCustomNav2ModuleFolder(folderName?: string | null): boolean { return /^\[lms-nav2-custom\][a-z][a-z0-9_]*_v[^/]+_ws$/.test(folderName?.trim() ?? '') } function validateCustomModuleScaffold(input: CustomModuleScaffoldInput): CustomModuleScaffoldInput { const name = input.name?.trim() const category = input.category?.trim() const folderName = input.folderName?.trim() const packageName = input.packageName?.trim() const launchFile = input.launchFile?.trim() const dockerImage = input.dockerImage?.trim() || null if (!name || !category || !folderName || !packageName || !launchFile) throw new Error('커스텀 모듈 정보가 올바르지 않습니다.') if (!isCustomNav2ModuleFolder(folderName) || !/^[a-z][a-z0-9_]*$/.test(packageName) || !/^[a-zA-Z0-9_.-]+\.launch\.py$/.test(launchFile)) { throw new Error('커스텀 모듈의 패키지명 또는 Launch 파일명이 올바르지 않습니다.') } if (dockerImage && (!/^[a-zA-Z0-9_./:@-]+$/.test(dockerImage) || dockerImage.length > 180)) { throw new Error('실행 이미지명이 올바르지 않습니다.') } return { name, category, folderName, packageName, launchFile, dockerImage } } function customModuleConfigContent(input: CustomModuleScaffoldInput): string { const module = validateCustomModuleScaffold(input) return `${JSON.stringify({ sourceType: 'CUSTOM', name: module.name, category: module.category, dockerImage: module.dockerImage, packageName: module.packageName, launchFile: module.launchFile, }, null, 2)}\n` } function buildCustomModuleWorkspaceFiles(input: CustomModuleScaffoldInput): Record { const module = validateCustomModuleScaffold(input) const header = [ '#!/usr/bin/env bash', 'set -euo pipefail', '', 'MODULE_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"', 'RUNTIME_ROOT="${AUTONOMY_STUDIO_RUNTIME_ROOT:-$HOME/autonomy-studio/runtime/nav2}"', 'FOLDER_NAME="$(basename "$MODULE_PATH")"', `DEFAULT_IMAGE=${quoteShellSingle(module.dockerImage || 'future-nav2:humble')}`, 'IMAGE="${NAV2_DOCKER_IMAGE:-$DEFAULT_IMAGE}"', ] return { '.autonomy-studio-module.json': customModuleConfigContent(module), '.gitignore': 'build/\ninstall/\nlog/\n__pycache__/\n*.py[cod]\n', 'build.sh': [...header, 'exec bash "$RUNTIME_ROOT/docker/docker_build_nav2.sh" "$(dirname "$MODULE_PATH")" "$FOLDER_NAME" "$IMAGE" "${ROS_DOMAIN_ID:-0}" "$@"', '', ].join('\n'), 'run.sh': [...header, 'CONTAINER_NAME="${FOLDER_NAME#\\[}"', 'CONTAINER_NAME="${CONTAINER_NAME/\\]/-}"', 'CONTAINER_NAME="${CONTAINER_NAME%_ws}"', 'exec bash "$RUNTIME_ROOT/docker/docker_run_nav2.sh" "$CONTAINER_NAME" "$(dirname "$MODULE_PATH")" "$FOLDER_NAME" "$IMAGE" "${ROS_DOMAIN_ID:-0}" "${GPU_ENABLED:-false}" "$@"', '', ].join('\n'), } } async function resolveCustomModuleImportSource(sourcePath: string): Promise { const selectedPath = sourcePath.trim() if (!selectedPath) throw new Error('불러올 모듈 폴더를 선택하세요.') const selectedRoot = resolve(selectedPath) let packageRoot = selectedRoot try { if (!(await stat(selectedRoot)).isDirectory()) throw new Error('not-a-directory') } catch (error) { if (error instanceof Error && error.message === 'not-a-directory') { throw new Error('선택한 경로가 폴더가 아닙니다.') } throw new Error('선택한 모듈 폴더를 찾을 수 없습니다.') } if (!existsSync(join(packageRoot, 'package.xml'))) { const sourceDirectory = join(selectedRoot, 'src') let packageDirectories: string[] = [] try { packageDirectories = (await readdir(sourceDirectory, { withFileTypes: true })) .filter((entry) => entry.isDirectory() && existsSync(join(sourceDirectory, entry.name, 'package.xml'))) .map((entry) => join(sourceDirectory, entry.name)) } catch { // The error below explains the required ROS workspace structure. } if (packageDirectories.length === 0) { throw new Error('package.xml을 찾을 수 없습니다. ROS2 패키지 폴더 또는 src 아래에 패키지가 하나 있는 워크스페이스를 선택하세요.') } if (packageDirectories.length > 1) { throw new Error('여러 ROS2 패키지가 있습니다. 불러올 패키지 폴더를 직접 선택하세요.') } packageRoot = packageDirectories[0] } const packageXml = await readFile(join(packageRoot, 'package.xml'), 'utf8') const packageName = packageXml.match(/\s*([^<]+?)\s*<\/name>/)?.[1]?.trim() ?? '' if (!/^[a-z][a-z0-9_]*$/.test(packageName)) { throw new Error('package.xml의 패키지명이 ROS2 패키지명 형식과 맞지 않습니다.') } const buildType = /\s*ament_python\s*<\/buildtool_depend>/.test(packageXml) ? 'ament_python' : /\s*ament_cmake\s*<\/buildtool_depend>/.test(packageXml) ? 'ament_cmake' : null if (!buildType) throw new Error('ament_python 또는 ament_cmake 기반 ROS2 패키지만 불러올 수 있습니다.') if (buildType === 'ament_python' && !existsSync(join(packageRoot, 'setup.py'))) { throw new Error('ament_python 패키지에 setup.py가 없습니다.') } if (buildType === 'ament_cmake' && !existsSync(join(packageRoot, 'CMakeLists.txt'))) { throw new Error('ament_cmake 패키지에 CMakeLists.txt가 없습니다.') } let launchFiles: string[] = [] try { launchFiles = (await readdir(join(packageRoot, 'launch'), { withFileTypes: true })) .filter((entry) => entry.isFile() && /^[a-zA-Z0-9_.-]+\.launch\.py$/.test(entry.name)) .map((entry) => entry.name) .sort((left, right) => left.localeCompare(right)) } catch { // The validation message below covers a missing launch directory too. } if (launchFiles.length === 0) { throw new Error('launch 폴더에 실행 가능한 *.launch.py 파일이 없습니다.') } return { sourcePath: packageRoot, packageName, launchFiles, buildType } } async function selectCustomModuleImportSource(): Promise { const selection = await dialog.showOpenDialog({ title: '모듈 불러오기', buttonLabel: '선택', properties: ['openDirectory'], }) if (selection.canceled || !selection.filePaths[0]) return null return resolveCustomModuleImportSource(selection.filePaths[0]) } const ignoredCustomModuleImportDirectories = new Set([ '.git', '.mypy_cache', '.pytest_cache', '__pycache__', 'build', 'install', 'log', 'node_modules', ]) async function importCustomWorkspaceModule( workspaceRoot: string | null | undefined, input: CustomModuleImportInput, ): Promise<{ ok: boolean; workspacePath: string }> { const module = validateCustomModuleScaffold(input) const source = await resolveCustomModuleImportSource(input.sourcePath) if (source.packageName !== module.packageName || !source.launchFiles.includes(module.launchFile)) { throw new Error('선택한 소스의 패키지 또는 Launch 파일 검증 결과가 현재 입력값과 다릅니다. 다시 선택하세요.') } const resolvedWorkspaceRoot = expandUserPath(workspaceRoot) if (!resolvedWorkspaceRoot) throw new Error('워크스페이스 경로가 설정되어 있지 않습니다.') const moduleRoot = resolve(resolvedWorkspaceRoot, module.folderName) if (moduleRoot === resolvedWorkspaceRoot || !moduleRoot.startsWith(`${resolvedWorkspaceRoot}/`)) { throw new Error('허용되지 않은 모듈 경로입니다.') } if (existsSync(moduleRoot)) throw new Error('같은 이름의 모듈이 이미 현재 워크스페이스에 있습니다.') await mkdir(resolvedWorkspaceRoot, { recursive: true }) try { const packageRoot = join(moduleRoot, 'src', module.packageName) await mkdir(dirname(packageRoot), { recursive: true }) await cp(source.sourcePath, packageRoot, { recursive: true, errorOnExist: true, force: false, filter: (currentPath) => { const pathParts = relative(source.sourcePath, currentPath).split(/[\\/]/).filter(Boolean) return !pathParts.some((part) => ignoredCustomModuleImportDirectories.has(part)) && relative(source.sourcePath, currentPath) !== '.autonomy-studio-module.json' }, }) for (const [fileName, content] of Object.entries(buildCustomModuleWorkspaceFiles(module))) { await writeFile(join(moduleRoot, fileName), content, { encoding: 'utf8', flag: 'wx', mode: fileName.endsWith('.sh') ? 0o755 : 0o644, }) } await runCommand('git', ['init', '--initial-branch=main'], moduleRoot, undefined, 15_000).catch(() => undefined) } catch (error) { await rm(moduleRoot, { recursive: true, force: true }).catch(() => undefined) throw error } return { ok: true, workspacePath: moduleRoot } } function buildCustomModuleFiles(input: CustomModuleScaffoldInput): Record { const module = validateCustomModuleScaffold(input) const executable = `${module.packageName}_node` const nodePath = `${module.packageName}/node.py` const packageFiles = { 'package.xml': [ '', '', ` ${module.packageName}`, ' 0.0.1', ` ${module.name}`, ' Autonomy Studio Student', ' Apache-2.0', ' ament_python', ' rclpy', ' launch', ' launch_ros', ' ament_python', '', '', ].join('\n'), 'setup.cfg': `[develop]\nscript_dir=$base/lib/${module.packageName}\n[install]\ninstall_scripts=$base/lib/${module.packageName}\n`, 'setup.py': [ 'from setuptools import setup', '', `package_name = '${module.packageName}'`, '', 'setup(', ' name=package_name,', " version='0.0.1',", ` packages=['${module.packageName}'],`, ` data_files=[('share/ament_index/resource_index/packages', ['resource/${module.packageName}']), ('share/${module.packageName}/launch', ['launch/${module.launchFile}']), ('share/${module.packageName}', ['package.xml'])],`, " install_requires=['setuptools'],", ' zip_safe=True,', " maintainer='Autonomy Studio Student',", " maintainer_email='student@autonomy-studio.local',", ` description='${module.name}',`, " license='Apache-2.0',", ` entry_points={'console_scripts': ['${executable} = ${module.packageName}.node:main']},`, ')', '', ].join('\n'), [`resource/${module.packageName}`]: '', [`${module.packageName}/__init__.py`]: '', [nodePath]: [ 'import rclpy', 'from rclpy.node import Node', '', 'class CustomModuleNode(Node):', ' def __init__(self):', ` super().__init__('${module.packageName}')`, ` self.get_logger().info('${module.name} started')`, '', 'def main(args=None):', ' rclpy.init(args=args)', ' node = CustomModuleNode()', ' try:', ' rclpy.spin(node)', ' finally:', ' node.destroy_node()', ' rclpy.shutdown()', '', "if __name__ == '__main__':", ' main()', '', ].join('\n'), [`launch/${module.launchFile}`]: [ 'from launch import LaunchDescription', 'from launch_ros.actions import Node', '', 'def generate_launch_description():', ' return LaunchDescription([', ` Node(package='${module.packageName}', executable='${executable}', name='${module.packageName}', output='screen'),`, ' ])', '', ].join('\n'), } return { ...buildCustomModuleWorkspaceFiles(module), ...Object.fromEntries(Object.entries(packageFiles).map(([path, content]) => [ `src/${module.packageName}/${path}`, content, ])), } } async function createCustomWorkspaceModule( workspaceRoot: string | null | undefined, input: CustomModuleScaffoldInput, ): Promise<{ ok: boolean; workspacePath: string }> { const module = validateCustomModuleScaffold(input) const resolvedWorkspaceRoot = expandUserPath(workspaceRoot) if (!resolvedWorkspaceRoot) throw new Error('워크스페이스 경로가 설정되어 있지 않습니다.') const moduleRoot = resolve(resolvedWorkspaceRoot, module.folderName) const files = buildCustomModuleFiles(module) const legacyLayout = existsSync(join(moduleRoot, 'package.xml')) await mkdir(moduleRoot, { recursive: true }) for (const [relativePath, content] of Object.entries(files)) { const filePath = legacyLayout && relativePath.startsWith(`src/${module.packageName}/`) ? relativePath.slice(`src/${module.packageName}/`.length) : relativePath const targetPath = resolve(moduleRoot, filePath) if (targetPath !== moduleRoot && !targetPath.startsWith(`${moduleRoot}/`)) throw new Error('허용되지 않은 모듈 파일 경로입니다.') await mkdir(dirname(targetPath), { recursive: true }) await writeFile(targetPath, content, { encoding: 'utf8', flag: 'wx', mode: filePath.endsWith('.sh') ? 0o755 : 0o644 }).catch((error: NodeJS.ErrnoException) => { if (error.code !== 'EEXIST') throw error }) } await runCommand('git', ['init', '--initial-branch=main'], moduleRoot, undefined, 15_000).catch(() => undefined) return { ok: true, workspacePath: moduleRoot } } async function createRemoteCustomWorkspaceModule( targetInput: RemoteWorkspaceTarget, input: CustomModuleScaffoldInput, ): Promise<{ ok: boolean; workspacePath: string }> { const target = normalizeRemoteTarget(targetInput) const module = validateCustomModuleScaffold(input) const files = buildCustomModuleFiles(module) const payload = encodeBase64(JSON.stringify({ folderName: module.folderName, packageName: module.packageName, files })) const output = await runSshOutput(target, [ 'set -e', `ROOT=${quoteShellSingle(target.workspaceRoot)}`, ...expandRemotePathVar('ROOT'), `PAYLOAD=$(printf %s ${quoteShellSingle(payload)} | base64 -d)`, 'python3 - "$ROOT" "$PAYLOAD" <<\'PY\'', 'import json, os, sys', 'root = os.path.abspath(os.path.expanduser(sys.argv[1]))', 'payload = json.loads(sys.argv[2])', 'folder = payload["folderName"]', 'target = os.path.abspath(os.path.join(root, folder))', 'if target == root or not target.startswith(root + os.sep): raise SystemExit("invalid module path")', 'os.makedirs(target, exist_ok=True)', 'legacy = os.path.isfile(os.path.join(target, "package.xml"))', 'prefix = "src/" + payload["packageName"] + "/"', 'for relative, content in payload["files"].items():', ' if legacy and relative.startswith(prefix): relative = relative[len(prefix):]', ' path = os.path.abspath(os.path.join(target, relative))', ' if not path.startswith(target + os.sep): raise SystemExit("invalid module file path")', ' os.makedirs(os.path.dirname(path), exist_ok=True)', ' try:', ' with open(path, "x", encoding="utf-8") as fp: fp.write(content)', ' if relative in ("build.sh", "run.sh"): os.chmod(path, 0o755)', ' except FileExistsError:', ' pass', 'print(target)', 'PY', `git -C "$ROOT/${module.folderName}" rev-parse --is-inside-work-tree >/dev/null 2>&1 || git -C "$ROOT/${module.folderName}" init -b main >/dev/null`, ].join('\n'), 30_000) return { ok: true, workspacePath: output.trim().split('\n')[0] || remotePathJoin(target.workspaceRoot, module.folderName) } } async function transferLocalWorkspaceModuleToRemote( targetInput: RemoteWorkspaceTarget, sourceWorkspaceRoot: string | null | undefined, moduleName: string, folderName?: string | null, ): Promise<{ ok: boolean; workspacePath: string }> { const target = normalizeRemoteTarget(targetInput) const sourceModuleRoot = await findWorkspaceModuleRoot(moduleName, sourceWorkspaceRoot, folderName) if (!sourceModuleRoot) throw new Error(`전송할 로컬 모듈을 찾을 수 없습니다: ${moduleName}`) const sourceStats = await stat(sourceModuleRoot) if (!sourceStats.isDirectory()) throw new Error(`전송할 로컬 모듈 경로가 올바르지 않습니다: ${moduleName}`) const resolvedFolderName = basename(sourceModuleRoot) if (!resolvedFolderName || resolvedFolderName === '.' || resolvedFolderName === '..') { throw new Error('전송할 모듈 폴더명이 올바르지 않습니다.') } const remoteCommand = [ 'set -e', `ROOT=${quoteShellSingle(target.workspaceRoot)}`, `FOLDER=${quoteShellSingle(resolvedFolderName)}`, ...expandRemotePathVar('ROOT'), 'STAGING="$ROOT/.incoming-$FOLDER"', 'rm -rf "$STAGING"', 'mkdir -p "$STAGING"', 'tar -xf - -C "$STAGING"', 'rm -rf "$ROOT/$FOLDER"', 'mv "$STAGING" "$ROOT/$FOLDER"', 'printf %s "$ROOT/$FOLDER"', ].join('\n') const sshArgs = [ '-p', String(target.port), '-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=accept-new', '-o', 'ConnectTimeout=5', '-i', target.identityFile, `${target.user}@${target.host}`, remoteCommand, ] await new Promise((resolvePromise, rejectPromise) => { let output = '' let settled = false const finish = (error?: Error): void => { if (settled) return settled = true clearTimeout(timeout) if (error) rejectPromise(error) else resolvePromise() } const archive = spawn('tar', [ '--exclude=.git', '--exclude=build', '--exclude=install', '--exclude=log', '--exclude=__pycache__', '-C', sourceModuleRoot, '-cf', '-', '.', ], { stdio: ['ignore', 'pipe', 'pipe'] }) const remote = spawn('ssh', sshArgs, { stdio: ['pipe', 'pipe', 'pipe'] }) const timeout = setTimeout(() => { archive.kill('SIGTERM') remote.kill('SIGTERM') finish(new Error('모듈 전송 시간이 초과되었습니다.')) }, 120_000) archive.stdout.pipe(remote.stdin) archive.stderr.on('data', (data: Buffer) => { output += data.toString() }) remote.stdout.on('data', (data: Buffer) => { output += data.toString() }) remote.stderr.on('data', (data: Buffer) => { output += data.toString() }) archive.on('error', (error) => finish(error)) remote.on('error', (error) => finish(error)) remote.on('close', (code) => { if (code === 0) finish() else finish(new Error(output.trim() || `모듈 전송에 실패했습니다. (ssh exit ${code ?? 'unknown'})`)) }) }) return { ok: true, workspacePath: remotePathJoin(target.workspaceRoot, resolvedFolderName) } } function resolveNav2RuntimeImage( runtimeImages?: RuntimeImageArtifact[] | null, folderName?: string | null, rosParams = '', ): string { // NAV2-MKMini normally targets the real platform image. Gazebo is an // explicit local opt-in, so existing platform launches keep their image. const mkminiGazebo = /lms-nav2-mkmini/i.test(folderName ?? '') && /(?:^|\s)simulator:=gazebo(?:\s|$)/.test(rosParams) const gazeboModule = isGazeboNav2ModuleFolder(folderName) || mkminiGazebo const gazeboImage = runtimeImages ?.find((image) => /nav2.*gazebo|gazebo.*nav2/i.test(image?.image?.trim() ?? '')) ?.image ?.trim() const nav2Image = runtimeImages ?.find((image) => { const imageName = image?.image?.trim() ?? '' return /nav2/i.test(imageName) && !/ros-bridge|gazebo/i.test(imageName) }) ?.image ?.trim() // Only Gazebo module folders and the explicit local MK-mini Gazebo option // use the Gazebo runtime image. Platform runs keep the shared NAV2 image. if (gazeboModule) return gazeboImage || nav2Image || 'future-nav2-gazebo:humble' return nav2Image || nav2DockerImage } function requiredRuntimeImages( runtimeImages: RuntimeImageArtifact[] | null | undefined, runtimeImage: string, ): RuntimeImageArtifact[] { const images = (runtimeImages ?? []).filter((image) => image?.image?.trim()) if (runtimeImage !== nav2DockerImage || images.some((image) => image.image?.trim() === runtimeImage)) { return images } return [...images, defaultNav2GazeboRuntimeImage] } async function resolveCustomModuleRuntimeImage(moduleRoot: string, fallbackImage: string): Promise { try { const config = JSON.parse(await readFile(resolve(moduleRoot, '.autonomy-studio-module.json'), 'utf8')) as { dockerImage?: unknown } const image = typeof config.dockerImage === 'string' ? config.dockerImage.trim() : '' return image && /^[a-zA-Z0-9_./:@-]+$/.test(image) ? image : fallbackImage } catch { return fallbackImage } } async function resolveRemoteNav2RuntimeImage( runtimeImages?: RuntimeImageArtifact[] | null, ): Promise { const configuredImage = runtimeImages ?.find((image) => { const imageName = image?.image?.trim() ?? '' return /nav2/i.test(imageName) && !/ros-bridge|gazebo/i.test(imageName) }) ?.image ?.trim() if (configuredImage) return configuredImage throw new Error('원격 플랫폼 NAV2 런타임 이미지가 설정되지 않았습니다.') } async function resolveRemoteCustomModuleRuntimeImage( target: RemoteWorkspaceTarget, folderName: string, fallbackImage: string, ): Promise { const normalizedTarget = normalizeRemoteTarget(target) const output = await runSshOutput(normalizedTarget, [ `ROOT=${quoteShellSingle(normalizedTarget.workspaceRoot)}`, ...expandRemotePathVar('ROOT'), `CONFIG="$ROOT/${folderName}/.autonomy-studio-module.json"`, 'if [ -f "$CONFIG" ]; then python3 - "$CONFIG" <<\'PY\'', 'import json, re, sys', 'image = json.load(open(sys.argv[1], encoding="utf-8")).get("dockerImage")', 'print(image.strip() if isinstance(image, str) and re.fullmatch(r"[A-Za-z0-9_./:@-]+", image.strip()) else "")', 'PY', 'fi', ].join('\n'), 8_000).catch(() => '') return output.trim() || fallbackImage } function isAllowedContainerName(containerKey: string): boolean { return /^[A-Za-z0-9_.-]+-[A-Za-z0-9_.-]+_v[A-Za-z0-9_.-]+$/.test(containerKey) } function isAllowedRuntimeContainerName(containerName: string): boolean { return /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/.test(containerName) } function isCacheModuleFolder(folderName: string): boolean { return folderName.endsWith('_ws') && !skippedCacheDirs.has(folderName) && !folderName.startsWith('.') } async function findCachedModuleRoot(moduleName: string): Promise { const explicitFolder = explicitModuleFolders[moduleName] if (explicitFolder) { const explicitPath = resolve(moduleCacheRoot, explicitFolder) try { if ((await stat(explicitPath)).isDirectory()) return explicitPath } catch { return null } } const normalizedModuleName = moduleName.toLowerCase() const entries = await readdir(moduleCacheRoot, { withFileTypes: true }) const matched = entries.find((entry) => { if (!entry.isDirectory() || !isCacheModuleFolder(entry.name)) return false const parsed = parseModuleFolderName(entry.name) return parsed.name.toLowerCase() === normalizedModuleName || entry.name.toLowerCase().includes(normalizedModuleName) }) return matched ? resolve(moduleCacheRoot, matched.name) : null } async function findWorkspaceModuleRoot(moduleName: string, workspaceRoot?: string | null, folderName?: string | null): Promise { const resolvedWorkspaceRoot = expandUserPath(workspaceRoot) if (!resolvedWorkspaceRoot) return null if (folderName?.trim()) { const directPath = resolve(resolvedWorkspaceRoot, folderName.trim()) try { if ((await stat(directPath)).isDirectory()) return directPath } catch { return null } } try { const entries = await readdir(resolvedWorkspaceRoot, { withFileTypes: true }) const matched = entries.find((entry) => { if (!entry.isDirectory()) return false const parsed = parseModuleFolderName(entry.name) return parsed.name.toLowerCase() === moduleName.toLowerCase() || entry.name.toLowerCase() === moduleName.toLowerCase() }) return matched ? resolve(resolvedWorkspaceRoot, matched.name) : null } catch { return null } } async function findModuleRoot(moduleName: string, workspaceRoot?: string | null, folderName?: string | null): Promise { return findWorkspaceModuleRoot(moduleName, workspaceRoot, folderName) } async function collectAvailableModules( workspaceRoot?: string | null, courseModules?: CourseModuleInput[] | null, ): Promise<{ modules: CachedModuleInfo[]; cacheRoot: string; workspaceRoot: string | null }> { const resolvedWorkspaceRoot = expandUserPath(workspaceRoot) const moduleInputs = (courseModules ?? []) .filter((module) => module.name?.trim() && module.folderName?.trim()) .map((module, index) => { const sortOrder = Number(module.sortOrder) return { ...module, sortOrder: Number.isFinite(sortOrder) ? sortOrder : index + 1 } }) .sort((a, b) => a.sortOrder - b.sortOrder) const dockerStatuses = await readDockerStatuses().catch(() => new Map()) const dockerStats = await readDockerStats().catch(() => new Map()) const modules = await Promise.all(moduleInputs .map(async (module): Promise => { const parsed = parseModuleFolderName(module.folderName) const dockerInfo = parseModuleDockerInfo(module.folderName) const name = module.name || parsed.name const category = module.category || parsed.category const sourcePath = resolve(moduleCacheRoot, module.folderName) const workspacePath = resolvedWorkspaceRoot ? resolve(resolvedWorkspaceRoot, module.folderName) : null const installed = workspacePath ? existsSync(workspacePath) : false const dockerStatus = dockerInfo ? dockerStatuses.get(dockerInfo.containerKey) : null const dockerStat = dockerInfo ? dockerStats.get(dockerInfo.containerKey) : null const rosParams = installed && workspacePath ? await readModuleRosParams(workspacePath) : '' return { id: module.id, state: installed ? (dockerStatus?.running ? 'running' : 'stopped') : 'uninstalled', sortOrder: module.sortOrder, category, name, folderName: module.folderName, sourcePath, workspacePath, templateRepositoryUrl: module.templateRepositoryUrl ?? null, sourceType: module.sourceType ?? null, prebuiltArtifacts: module.prebuiltArtifacts ?? null, dockerImage: module.dockerImage ?? null, packageName: module.packageName ?? null, launchFile: module.launchFile ?? null, runtimeTarget: module.runtimeTarget ?? null, parameters: splitRosParams(rosParams), cpu: dockerStatus?.running ? dockerStat?.cpu ?? null : null, memory: dockerStatus?.running ? dockerStat?.memory ?? null : null, runtime: dockerStatus?.running ? formatDockerRuntime(dockerStatus.status) : '-' } })) return { modules: modules.sort((a, b) => a.sortOrder - b.sortOrder), cacheRoot: moduleCacheRoot, workspaceRoot: resolvedWorkspaceRoot } } async function collectRemoteAvailableModules( targetInput?: RemoteWorkspaceTarget | null, courseModules?: CourseModuleInput[] | null, ): Promise<{ modules: CachedModuleInfo[]; cacheRoot: string; workspaceRoot: string | null }> { const target = normalizeRemoteTarget(targetInput) const moduleInputs = (courseModules ?? []) .filter((module) => module.name?.trim() && module.folderName?.trim()) .map((module, index) => { const sortOrder = Number(module.sortOrder) return { ...module, sortOrder: Number.isFinite(sortOrder) ? sortOrder : index + 1 } }) .sort((a, b) => a.sortOrder - b.sortOrder) const [output, statsOutput] = await Promise.all([ runSshOutput(targetInput ?? target, buildRemoteModuleProbeScript(target.workspaceRoot, moduleInputs), 30_000), readRemoteDockerStatsOutput(target), ]) const dockerStatuses = new Map() const dockerStats = new Map() const installedModules = new Map() for (const line of `${output}\n${statsOutput}`.split('\n')) { if (!line.trim()) continue const [kind, first = '', second = '', third = ''] = line.split('\t') if (kind === 'DOCKER' && first) { dockerStatuses.set(first, { exists: true, running: /^Up\b/i.test(second), status: second }) } if (kind === 'STATS' && first) { const cpu = parseDockerPercent(second) const memory = parseDockerPercent(third) if (cpu !== null && memory !== null) dockerStats.set(first, { cpu, memory }) } if (kind === 'MODULE' && first && second) { installedModules.set(first, { path: second, parameters: splitRosParams(decodeRemoteField(third)) }) } } const modules = moduleInputs.map((module): CachedModuleInfo => { const parsed = parseModuleFolderName(module.folderName) const dockerInfo = parseModuleDockerInfo(module.folderName) const installed = installedModules.get(module.folderName) const dockerStatus = dockerInfo ? dockerStatuses.get(dockerInfo.containerKey) : null const dockerStat = dockerInfo ? dockerStats.get(dockerInfo.containerKey) : null return { id: module.id, state: installed ? (dockerStatus?.running ? 'running' : 'stopped') : 'uninstalled', sortOrder: module.sortOrder, category: module.category || parsed.category, name: module.name || parsed.name, folderName: module.folderName, sourcePath: module.templateRepositoryUrl ?? '', workspacePath: installed?.path ?? null, templateRepositoryUrl: module.templateRepositoryUrl ?? null, sourceType: module.sourceType ?? null, prebuiltArtifacts: module.prebuiltArtifacts ?? null, dockerImage: module.dockerImage ?? null, packageName: module.packageName ?? null, launchFile: module.launchFile ?? null, runtimeTarget: module.runtimeTarget ?? null, parameters: installed?.parameters ?? [], cpu: dockerStatus?.running ? dockerStat?.cpu ?? null : null, memory: dockerStatus?.running ? dockerStat?.memory ?? null : null, runtime: dockerStatus?.running ? formatDockerRuntime(dockerStatus.status) : '-' } }) return { modules, cacheRoot: remoteSshDestination(targetInput ?? target), workspaceRoot: target.workspaceRoot } } function parseDockerPercent(value: string): number | null { const numeric = Number(value.replace('%', '').trim()) return Number.isFinite(numeric) ? numeric : null } function formatDockerRuntime(status: string): string { const match = status.match(/^Up\s+(.+?)(?:\s+\(|$)/i) const runtime = match?.[1]?.trim() if (!runtime) return '실행 중' const normalized = runtime.toLowerCase() if (normalized === 'less than a second') return '1 sec' return runtime .replace(/\babout\s+/gi, '') .replace(/\ban?\s+(seconds?|minutes?|hours?|days?|weeks?|months?|years?)\b/gi, '1 $1') .replace(/\bseconds?\b/gi, 'sec') .replace(/\bminutes?\b/gi, 'min') .replace(/\bhours?\b/gi, 'h') .replace(/\bdays?\b/gi, 'd') .replace(/\bweeks?\b/gi, 'w') .replace(/\bmonths?\b/gi, 'mo') .replace(/\byears?\b/gi, 'y') .replace(/\s+/g, ' ') .trim() } function withGitCredential(repositoryUrl: string): string { const token = process.env.GITEA_TOKEN?.trim() if (!token) return repositoryUrl try { const url = new URL(repositoryUrl) if (url.protocol !== 'http:' && url.protocol !== 'https:') return repositoryUrl url.username = 'oauth2' url.password = token return url.toString() } catch { return repositoryUrl } } function remoteRepositoryUrl(repositoryUrl: string): string { try { const url = new URL(repositoryUrl) const host = url.hostname.toLowerCase() if (host === 'localhost' || host === '127.0.0.1' || host === '::1') { url.hostname = process.env.AUTONOMY_STUDIO_REMOTE_GITEA_HOST?.trim() || '192.168.0.242' return url.toString() } } catch (error) { return repositoryUrl } return repositoryUrl } async function runCommandOutput(command: string, args: string[], cwd?: string, env?: NodeJS.ProcessEnv, timeoutMs = 120_000): Promise { return new Promise((resolvePromise, rejectPromise) => { let output = '' let terminated = false const child = spawn(command, args, { cwd, detached: process.platform !== 'win32', stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, ...env, GIT_TERMINAL_PROMPT: '0' } }) const timeout = setTimeout(() => { terminated = true if (process.platform !== 'win32' && child.pid) { const processId = child.pid // SSH/SCP spawn a child process. Kill the whole group so a timed-out // transfer cannot leave an SSH child alive in the background. try { process.kill(-processId, 'SIGTERM') } catch { /* already exited */ } setTimeout(() => { try { process.kill(-processId, 'SIGKILL') } catch { /* already exited */ } }, 1_000).unref() } else if (!child.killed) { child.kill('SIGTERM') } rejectPromise(new Error(`${command} ${args.join(' ')} timed out`)) }, timeoutMs) child.stdout.on('data', (data: Buffer) => { output += data.toString() }) child.stderr.on('data', (data: Buffer) => { output += data.toString() }) child.on('error', (error) => { clearTimeout(timeout) rejectPromise(error) }) child.on('close', (code) => { clearTimeout(timeout) if (terminated) return if (code === 0) resolvePromise(output) else rejectPromise(new Error(output.trim() || `${command} ${args.join(' ')} failed with exit code ${code ?? 'unknown'}`)) }) }) } async function runCommand(command: string, args: string[], cwd?: string, env?: NodeJS.ProcessEnv, timeoutMs?: number): Promise { await runCommandOutput(command, args, cwd, env, timeoutMs) } async function listLocalDockerImages(): Promise<{ images: string[]; message?: string }> { try { const output = await runCommandOutput('docker', ['image', 'ls', '--format', '{{json .}}'], undefined, undefined, 10_000) const images = Array.from(new Set(output.split('\n').flatMap((line) => { if (!line.trim()) return [] try { const image = JSON.parse(line) as { Repository?: unknown; Tag?: unknown } const repository = typeof image.Repository === 'string' ? image.Repository.trim() : '' const tag = typeof image.Tag === 'string' ? image.Tag.trim() : '' if (!repository || !tag || repository === '' || tag === '') return [] return [`${repository}:${tag}`] } catch { return [] } }))).sort((left, right) => left.localeCompare(right)) return { images } } catch (error) { return { images: [], message: error instanceof Error ? error.message : '로컬 Docker 이미지를 불러오지 못했습니다.' } } } const rosGraphMarker = '__AUTONOMY_STUDIO_ROS_GRAPH__' const rosGraphProbeScript = [ 'import json, rclpy', 'from rclpy.node import Node', 'rclpy.init()', 'node = Node("autonomy_studio_graph_probe")', 'try:', ' for _ in range(4): rclpy.spin_once(node, timeout_sec=0.2)', ' def endpoint(info):', ' namespace = (getattr(info, "node_namespace", "") or "").rstrip("/")', ' name = getattr(info, "node_name", "") or "unknown"', ' return {"node": (namespace + "/" + name) if namespace else "/" + name, "type": getattr(info, "topic_type", "") or ""}', ' topics = []', ' for name, types in sorted(node.get_topic_names_and_types()):', ' if name in ("/parameter_events", "/rosout"): continue', ' topics.append({"name": name, "types": list(types), "publishers": [endpoint(item) for item in node.get_publishers_info_by_topic(name)], "subscribers": [endpoint(item) for item in node.get_subscriptions_info_by_topic(name)]})', ' payload = {"topics": topics}', 'except Exception as error:', ' payload = {"topics": [], "message": str(error)}', 'finally:', ' node.destroy_node()', ' rclpy.shutdown()', `print(${JSON.stringify(rosGraphMarker)} + json.dumps(payload, separators=(",", ":")))`, ].join('\n') function normalizeRosGraphSnapshot(value: unknown): RosGraphSnapshot { const snapshot = value && typeof value === 'object' ? value as Partial : {} const topics = Array.isArray(snapshot.topics) ? snapshot.topics.flatMap((topic): RosGraphTopic[] => { if (!topic || typeof topic !== 'object') return [] const candidate = topic as Partial if (typeof candidate.name !== 'string' || !candidate.name.startsWith('/')) return [] const normalizeEndpoints = (entries: unknown): RosGraphEndpoint[] => Array.isArray(entries) ? entries.flatMap((entry): RosGraphEndpoint[] => { if (!entry || typeof entry !== 'object') return [] const endpoint = entry as Partial return typeof endpoint.node === 'string' && endpoint.node.trim() ? [{ node: endpoint.node.trim(), type: typeof endpoint.type === 'string' ? endpoint.type : '' }] : [] }) : [] return [{ name: candidate.name, types: Array.isArray(candidate.types) ? candidate.types.filter((type): type is string => typeof type === 'string') : [], publishers: normalizeEndpoints(candidate.publishers), subscribers: normalizeEndpoints(candidate.subscribers), }] }) : [] return { topics, ...(typeof snapshot.message === 'string' && snapshot.message ? { message: snapshot.message } : {}) } } function parseRosGraphSnapshot(output: string): RosGraphSnapshot { const markerIndex = output.lastIndexOf(rosGraphMarker) if (markerIndex < 0) return { topics: [], message: 'ROS 그래프 조회 결과를 읽지 못했습니다.' } try { return normalizeRosGraphSnapshot(JSON.parse(output.slice(markerIndex + rosGraphMarker.length).trim())) } catch { return { topics: [], message: 'ROS 그래프 조회 결과 형식이 올바르지 않습니다.' } } } function pickRosGraphProbeContainer(containerNames: string[]): string | null { const bridgeContainer = ['slam-ros-bridge', 'nav2-ros-bridge', 'autoware-ros-bridge'].find((name) => containerNames.includes(name)) if (bridgeContainer) return bridgeContainer return containerNames.find((name) => name.startsWith('lms-nav2-') && isAllowedRuntimeContainerName(name)) ?? null } function rosGraphProbeCommand(): string { const encodedScript = Buffer.from(rosGraphProbeScript, 'utf8').toString('base64') return [ 'source /opt/ros/humble/setup.bash >/dev/null 2>&1 || true', `printf %s ${quoteShellSingle(encodedScript)} | base64 -d | python3`, ].join(' && ') } async function collectLocalRosGraph(): Promise { try { const names = (await runCommandOutput('docker', ['ps', '--format', '{{.Names}}'], undefined, undefined, 8_000)) .split('\n').map((line) => line.trim()).filter(Boolean) const containerName = pickRosGraphProbeContainer(names) if (!containerName) return { topics: [] } const output = await runCommandOutput('docker', ['exec', containerName, 'bash', '-lc', rosGraphProbeCommand()], undefined, undefined, 12_000) return parseRosGraphSnapshot(output) } catch (error) { return { topics: [], message: error instanceof Error ? error.message : 'ROS 그래프 조회에 실패했습니다.' } } } async function collectRemoteRosGraph(targetInput: RemoteWorkspaceTarget): Promise { try { const target = normalizeRemoteTarget(targetInput) const names = (await runSshOutput(target, 'timeout 8s docker ps --format "{{.Names}}" 2>/dev/null || true', 15_000)) .split('\n').map((line) => line.trim()).filter(Boolean) const containerName = pickRosGraphProbeContainer(names) if (!containerName) return { topics: [] } const output = await runSshOutput(target, `timeout 12s docker exec ${quoteShellSingle(containerName)} bash -lc ${quoteShellSingle(rosGraphProbeCommand())}`, 20_000) return parseRosGraphSnapshot(output) } catch (error) { return { topics: [], message: error instanceof Error ? error.message : 'ROS 그래프 조회에 실패했습니다.' } } } function safeArtifactFileName(url: string, fileName?: string | null): string { const rawName = fileName?.trim() || basename(new URL(url).pathname) || 'artifact.tar' return rawName.replace(/[^a-zA-Z0-9._-]/g, '_') } function artifactCachePath(url: string, fileName?: string | null): string { return resolve(artifactCacheRoot, safeArtifactFileName(url, fileName)) } async function downloadArtifact(url: string, targetPath: string, redirects = 0): Promise { if (redirects > 5) throw new Error('artifact redirect limit exceeded') await mkdir(dirname(targetPath), { recursive: true }) await new Promise((resolvePromise, rejectPromise) => { const parsed = new URL(url) const client = parsed.protocol === 'http:' ? http : https const request = client.get(parsed, (response) => { const statusCode = response.statusCode ?? 0 const location = response.headers.location if (statusCode >= 300 && statusCode < 400 && location) { response.resume() const nextUrl = new URL(location, parsed).toString() downloadArtifact(nextUrl, targetPath, redirects + 1).then(resolvePromise, rejectPromise) return } if (statusCode < 200 || statusCode >= 300) { response.resume() rejectPromise(new Error(`artifact download failed: HTTP ${statusCode}`)) return } const file = createWriteStream(targetPath) response.pipe(file) file.on('finish', () => file.close(() => resolvePromise())) file.on('error', rejectPromise) }) request.on('error', rejectPromise) }) } async function ensureArtifactFile(url?: string | null, fileName?: string | null, checksumSha256?: string | null): Promise { const artifactUrl = url?.trim() if (!artifactUrl) return null const targetPath = artifactCachePath(artifactUrl, fileName) const expected = checksumSha256?.trim().toLowerCase() // Module artifact URLs are intentionally stable (for example, `latest`). // Do not reuse a same-named local file, or a source update can run with an // older install tree even though R2 already contains the current artifact. await rm(targetPath, { force: true }) await downloadArtifact(artifactUrl, targetPath) if (expected) { let actual = createHash('sha256').update(await readFile(targetPath)).digest('hex').toLowerCase() if (actual !== expected) { // Artifact URLs are stable, so an existing filename can be an older cached release. await rm(targetPath, { force: true }) await downloadArtifact(artifactUrl, targetPath) actual = createHash('sha256').update(await readFile(targetPath)).digest('hex').toLowerCase() if (actual !== expected) { await rm(targetPath, { force: true }).catch(() => undefined) throw new Error(`artifact checksum mismatch: ${safeArtifactFileName(artifactUrl, fileName)}`) } } } return targetPath } function tarArgsFor(filePath: string, mode: 'list' | 'extract'): string[] { const lower = filePath.toLowerCase() if (lower.endsWith('.tar.zst') || lower.endsWith('.tzst')) { const zstdPath = getBundledZstdPath() const compressionArgs = zstdPath ? [`--use-compress-program=${zstdPath}`] : ['--zstd'] return mode === 'list' ? [...compressionArgs, '-tf'] : [...compressionArgs, '-xf'] } if (lower.endsWith('.tar.gz') || lower.endsWith('.tgz')) return mode === 'list' ? ['-tzf'] : ['-xzf'] return mode === 'list' ? ['-tf'] : ['-xf'] } function ensureSafeTarEntry(entry: string): void { const normalized = entry.trim() if (!normalized || normalized.startsWith('/') || normalized.includes('../') || normalized === '..' || normalized.startsWith('..\\')) { throw new Error(`unsafe artifact path: ${entry}`) } } async function extractArtifact(filePath: string, targetRoot: string, extractPath?: string | null): Promise { const destination = resolve(targetRoot, extractPath?.trim() || '.') if (destination !== targetRoot && !destination.startsWith(`${targetRoot}/`)) throw new Error('artifact extract path is outside module root') const entries = await runCommandOutput('tar', [...tarArgsFor(filePath, 'list'), filePath], undefined, undefined, 120000) entries.split('\n').filter(Boolean).forEach(ensureSafeTarEntry) await mkdir(destination, { recursive: true }) await runCommand('tar', [...tarArgsFor(filePath, 'extract'), filePath, '-C', destination], undefined, undefined, 900000) } async function applyModulePrebuiltArtifacts(targetRoot: string, artifacts?: ModulePrebuiltArtifact[] | null): Promise { const architecture = process.arch === 'x64' ? 'amd64' : process.arch === 'arm64' ? 'arm64' : process.arch const normalizedArtifacts = (artifacts ?? []).filter((artifact) => { if (!artifact?.url?.trim()) return false const targetArchitecture = artifact.architecture?.trim().toLowerCase() return !targetArchitecture || targetArchitecture === architecture }) for (const artifact of normalizedArtifacts) { const filePath = await ensureArtifactFile(artifact.url, artifact.fileName, artifact.checksumSha256) if (!filePath) { if (artifact.required) throw new Error('required module artifact url is missing') continue } await extractArtifact(filePath, targetRoot, artifact.extractPath) } } async function prepareRuntimeImages(images?: RuntimeImageArtifact[] | null): Promise { const normalizedImages = (images ?? []).filter((image) => image?.image?.trim()) for (const image of normalizedImages) { const imageName = image.image?.trim() if (!imageName) continue const exists = await runCommand('docker', ['image', 'inspect', imageName], undefined, undefined, 10000) .then(() => true) .catch(() => false) if (exists) continue const filePath = await ensureArtifactFile(image.url, image.fileName, image.checksumSha256) if (!filePath) { if (image.required) throw new Error(`runtime image artifact url is missing: ${imageName}`) continue } if (filePath.toLowerCase().endsWith('.tar.zst') || filePath.toLowerCase().endsWith('.tzst')) { const zstdPath = getBundledZstdPath() ?? 'zstd' await runCommand('bash', ['-lc', '"$1" -dc "$2" | docker load', 'autonomy-studio-runtime-image', zstdPath, filePath], undefined, undefined, 900000) } else { await runCommand('docker', ['load', '-i', filePath], undefined, undefined, 900000) } } } function defaultSshIdentityFile(): string { return join(homedir(), '.autonomy-studio', 'ssh', 'id_ed25519') } function remoteUserHome(user: string): string { return user === 'root' ? '/root' : `/home/${user}` } function normalizeRemoteWorkspaceRoot(value: string | null | undefined, user: string): string { const raw = value?.trim() || '~/autonomy-studio/workspaces' if (raw === '~') return remoteUserHome(user) if (raw.startsWith('~/')) return `${remoteUserHome(user)}/${raw.slice(2)}` return raw } function normalizeRemoteTarget(target?: RemoteWorkspaceTarget | null): Required> & { workspaceRoot: string; identityFile: string } { const host = target?.host?.trim() if (!host) throw new Error('원격 플랫폼 SSH 호스트를 확인하지 못했습니다.') const user = target?.user?.trim() || process.env.AUTONOMY_STUDIO_SSH_USER?.trim() if (!user) throw new Error('원격 플랫폼 SSH 사용자를 확인하지 못했습니다.') const port = Number(target?.port ?? process.env.AUTONOMY_STUDIO_SSH_PORT ?? 22) return { host, user, port: Number.isFinite(port) && port > 0 ? port : 22, workspaceRoot: normalizeRemoteWorkspaceRoot(target?.workspaceRoot, user), identityFile: target?.identityFile?.trim() || process.env.AUTONOMY_STUDIO_SSH_IDENTITY_FILE?.trim() || defaultSshIdentityFile() } } function remoteSshDestination(target: RemoteWorkspaceTarget): string { const normalized = normalizeRemoteTarget(target) return `${normalized.user}@${normalized.host}` } async function queueRemoteSsh(target: ReturnType, task: () => Promise): Promise { const key = `${target.user}@${target.host}:${target.port}` const previous = remoteSshQueues.get(key) ?? Promise.resolve() let release: () => void = () => undefined const current = new Promise((resolvePromise) => { release = resolvePromise }) const queued = previous.catch(() => undefined).then(() => current) remoteSshQueues.set(key, queued) await previous.catch(() => undefined) try { return await task() } finally { release() if (remoteSshQueues.get(key) === queued) remoteSshQueues.delete(key) } } async function runSshOutput(target: RemoteWorkspaceTarget, command: string, timeoutMs = 120_000): Promise { const normalized = normalizeRemoteTarget(target) await mkdir(remoteSshControlDirectory, { recursive: true }) return queueRemoteSsh(normalized, () => runCommandOutput( 'ssh', [ '-p', String(normalized.port), '-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=accept-new', '-o', 'ConnectTimeout=5', '-o', 'PreferredAuthentications=publickey', '-o', 'PasswordAuthentication=no', '-o', 'KbdInteractiveAuthentication=no', '-o', 'ControlMaster=auto', '-o', 'ControlPersist=120', '-o', `ControlPath=${join(remoteSshControlDirectory, 'cm-%C')}`, '-i', normalized.identityFile, `${normalized.user}@${normalized.host}`, command ], undefined, undefined, timeoutMs )) } async function runUnqueuedSshOutput(target: RemoteWorkspaceTarget, command: string, timeoutMs: number): Promise { const normalized = normalizeRemoteTarget(target) return runCommandOutput( 'ssh', [ '-p', String(normalized.port), '-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=accept-new', '-o', 'ConnectTimeout=5', '-o', 'PreferredAuthentications=publickey', '-o', 'PasswordAuthentication=no', '-o', 'KbdInteractiveAuthentication=no', '-o', 'ControlMaster=no', '-i', normalized.identityFile, `${normalized.user}@${normalized.host}`, command, ], undefined, undefined, timeoutMs, ) } async function readRemoteDockerStatsOutput(target: RemoteWorkspaceTarget): Promise { const normalized = normalizeRemoteTarget(target) const key = `${normalized.user}@${normalized.host}:${normalized.port}` if ((remoteDockerStatsRetryAfter.get(key) ?? 0) > Date.now()) return '' try { const output = await runUnqueuedSshOutput( normalized, 'timeout -k 1s 8s docker stats --no-stream --format "STATS\\t{{.Name}}\\t{{.CPUPerc}}\\t{{.MemPerc}}" 2>/dev/null || true', 15_000, ) remoteDockerStatsRetryAfter.delete(key) return output } catch { // Do not spawn another blocked Docker client on every ten-second module // refresh when the Jetson Docker daemon is under load. remoteDockerStatsRetryAfter.set(key, Date.now() + 60_000) return '' } } async function runSsh(target: RemoteWorkspaceTarget, command: string, timeoutMs?: number): Promise { await runSshOutput(target, command, timeoutMs) } async function copyRemoteFileToLocal( target: RemoteWorkspaceTarget, remotePath: string, localPath: string, timeoutMs = 900_000, ): Promise { const normalized = normalizeRemoteTarget(target) const host = normalized.host.includes(':') ? `[${normalized.host}]` : normalized.host // A large 3D map database can take several minutes over a relayed Tailscale // link. Keep that file transfer outside the serialized command queue so it // cannot prevent module state, stop, or control requests from reaching the // platform in the meantime. await runCommand( 'scp', [ '-q', '-P', String(normalized.port), '-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=accept-new', '-o', 'ConnectTimeout=5', '-o', 'PreferredAuthentications=publickey', '-o', 'PasswordAuthentication=no', '-o', 'KbdInteractiveAuthentication=no', '-o', 'ControlMaster=no', '-i', normalized.identityFile, `${normalized.user}@${host}:${remotePath}`, localPath, ], undefined, undefined, timeoutMs, ) } async function readRemoteSavedMapArtifacts( targetInput: RemoteWorkspaceTarget, artifacts: SavedMapArtifactPath[], ): Promise> { const target = normalizeRemoteTarget(targetInput) return Promise.all(artifacts.map(async (artifact) => { const fileName = savedMapArtifactFileName(artifact.name, artifact.path) const mapPath = `${target.workspaceRoot}/maps/${fileName}` const base64 = (await runSshOutput( target, `test -f ${quoteShellSingle(mapPath)} && base64 -w0 ${quoteShellSingle(mapPath)}`, 120_000, )).trim() if (!base64) throw new Error(`저장된 맵 파일을 읽을 수 없습니다: ${fileName}`) return { name: artifact.name, fileName, base64 } })) } async function uploadRemoteRtabmapDatabase( targetInput: RemoteWorkspaceTarget, uploadUrl: string, databasePath?: string | null, ): Promise<{ ok: boolean }> { const target = normalizeRemoteTarget(targetInput) const url = new URL(uploadUrl) if (url.protocol !== 'https:' || !url.hostname.endsWith('.r2.cloudflarestorage.com') || url.username || url.password || url.port || !url.searchParams.has('X-Amz-Signature')) { throw new Error('R2 업로드 URL이 올바르지 않습니다.') } const fileName = databasePath ? savedMapArtifactFileName('database', databasePath) : 'rtabmap-active.db' const sourcePath = `${target.workspaceRoot}/maps/${fileName}` const snapshotScript = [ 'import sqlite3, sys', 'source = sqlite3.connect("file:" + sys.argv[1] + "?mode=ro", uri=True)', 'destination = sqlite3.connect(sys.argv[2])', 'source.backup(destination)', 'destination.close()', 'source.close()', ].join('; ') const script = [ 'set -e', `SOURCE=${quoteShellSingle(sourcePath)}`, 'test -f "$SOURCE"', 'command -v curl >/dev/null', 'SNAPSHOT=$(mktemp --suffix=.db)', 'trap \'rm -f "$SNAPSHOT"\' EXIT', `timeout -k 2s 120s python3 -c ${quoteShellSingle(snapshotScript)} "$SOURCE" "$SNAPSHOT"`, `curl --fail --silent --show-error --connect-timeout 15 --max-time 900 --header 'Content-Type: application/octet-stream' --upload-file "$SNAPSHOT" --output /dev/null ${quoteShellSingle(uploadUrl)}`, ].join('\n') try { // Only commands cross SSH; the database bytes go from the platform to R2. await runUnqueuedSshOutput(target, script, 1_100_000) } catch { // The command contains a signed URL; do not expose it in renderer errors. throw new Error('플랫폼의 3D DB R2 업로드에 실패했습니다. DB 파일, curl 설치 및 인터넷 연결을 확인해 주세요.') } return { ok: true } } async function copyRemoteActiveRtabmapDatabaseToLocal( targetInput: RemoteWorkspaceTarget, localWorkspaceRoot: string | null | undefined, destinationPath: string, ): Promise<{ databasePath: string }> { const target = normalizeRemoteTarget(targetInput) const destinationFileName = savedMapArtifactFileName('database', destinationPath) const sourcePath = `${target.workspaceRoot}/maps/rtabmap-active.db` const snapshotScript = [ 'import sqlite3, sys', 'source, destination = sys.argv[1:3]', 'source_db = sqlite3.connect(f"file:{source}?mode=ro", uri=True)', 'destination_db = sqlite3.connect(destination)', 'source_db.backup(destination_db)', 'destination_db.close()', 'source_db.close()', ].join('; ') const createSnapshotCommand = [ 'set -e', `SOURCE=${quoteShellSingle(sourcePath)}`, 'test -f "$SOURCE" || { echo "3D SLAM DB를 찾을 수 없습니다: $SOURCE" >&2; exit 1; }', 'SNAPSHOT=$(mktemp --suffix=.db)', `python3 -c ${quoteShellSingle(snapshotScript)} "$SOURCE" "$SNAPSHOT"`, 'printf "%s" "$SNAPSHOT"', ].join('\n') await syncWorkspaceSavedMapArtifacts(localWorkspaceRoot, []) const root = expandUserPath(localWorkspaceRoot) if (!root) throw new Error('로컬 워크스페이스 경로가 설정되어 있지 않습니다.') const mapsDirectory = resolve(root, 'maps') const destination = resolve(mapsDirectory, destinationFileName) if (!destination.startsWith(`${mapsDirectory}/`)) throw new Error('저장된 3D 맵 경로가 올바르지 않습니다.') const snapshotPath = (await runSshOutput(target, createSnapshotCommand, 120_000)).trim() if (!snapshotPath.startsWith('/tmp/')) throw new Error('3D SLAM DB 스냅샷을 만들지 못했습니다.') const temporaryDestination = `${destination}.download-${process.pid}-${Date.now()}` try { await copyRemoteFileToLocal(target, snapshotPath, temporaryDestination) await rename(temporaryDestination, destination) } finally { await rm(temporaryDestination, { force: true }).catch(() => undefined) await runSsh(target, `rm -f ${quoteShellSingle(snapshotPath)}`, 15_000).catch(() => undefined) } return { databasePath: `/workspace/maps/${destinationFileName}` } } async function syncRemoteSavedMapArtifacts( targetInput: RemoteWorkspaceTarget, artifacts: SavedMapArtifactDownload[], ): Promise<{ ok: boolean }> { const target = normalizeRemoteTarget(targetInput) const downloads = artifacts.map(validateSavedMapDownload) const mapsDirectory = `${target.workspaceRoot}/maps` const command = [ 'set -e', `ROOT=${quoteShellSingle(target.workspaceRoot)}`, `MAPS_DIRECTORY=${quoteShellSingle(mapsDirectory)}`, 'if [ ! -d "$MAPS_DIRECTORY" ] || [ ! -w "$MAPS_DIRECTORY" ]; then', ' if docker image inspect future-nav2:humble-jetson_orin >/dev/null 2>&1; then', ' IMAGE="future-nav2:humble-jetson_orin"', ' elif docker image inspect future-nav2:humble >/dev/null 2>&1; then', ' IMAGE="future-nav2:humble"', ' else', ' IMAGE=""', ' fi', ' if [ -z "$IMAGE" ]; then echo "저장된 맵 디렉터리에 쓸 권한이 없고 권한 복구용 NAV2 이미지를 찾을 수 없습니다." >&2; exit 1; fi', ' docker run --rm --user 0:0 -v "$ROOT:/workspace" --entrypoint /bin/sh "$IMAGE" -lc "mkdir -p /workspace/maps && chown -R $(id -u):$(id -g) /workspace/maps"', 'fi', 'mkdir -p "$MAPS_DIRECTORY"', ...downloads.map((artifact) => ( `curl -fsSL --retry 2 ${quoteShellSingle(artifact.url)} -o ${quoteShellSingle(`${mapsDirectory}/${artifact.fileName}`)}` )), ].join('\n') await runSsh(target, command, 120_000) return { ok: true } } async function readRemoteVisualizationBridgeStatus(target: RemoteWorkspaceTarget): Promise { try { const output = await runSshOutput( target, 'docker ps --format "{{.Names}}\\t{{.Status}}" 2>/dev/null || true', 10_000, ) const nav2 = output.split('\n').find((line) => line.startsWith('nav2-ros-bridge\t')) const autoware = output.split('\n').find((line) => line.startsWith('autoware-ros-bridge\t')) if (nav2) return { ok: true, running: true, stack: 'nav2' } if (autoware) return { ok: true, running: true, stack: 'autoware' } return { ok: true, running: false, stack: null } } catch (error) { return { ok: false, running: false, stack: null, message: error instanceof Error ? error.message : String(error) } } } async function startRemoteVisualizationBridge( target: RemoteWorkspaceTarget, stackInput?: string | null, rosDomainIdInput?: string | null, environmentProfile?: EnvironmentProfileConfig | null, rosNetwork?: RosNetworkConfig | null, ): Promise { const stack = normalizeVisualizationStack(stackInput) const rosDomainId = String(rosDomainIdInput ?? '0').trim() || '0' const normalized = normalizeRemoteTarget(target) const root = normalized.workspaceRoot const fastDdsConfigPath = await prepareRemoteRosNetworkConfig(normalized, '~/.autonomy-studio/runtime/nav2', rosNetwork, rosDomainId) const containerName = visualizationBridgeContainerName(stack) const bridgeEnvArgs = Object.entries(environmentProfile?.bridge?.env ?? {}) .filter(([key]) => /^[A-Z_][A-Z0-9_]*$/.test(key)) .map(([key, value]) => `-e ${quoteShellSingle(`${key}=${value}`)}`) .join(' ') let image = resolveVisualizationBridgeImage(stack, environmentProfile?.bridge, environmentProfile?.runtimeImages) const remoteJetsonBridgeImage = environmentProfile?.runtimeImages ?.find((artifact) => ( /^slam-ros-bridge:jetson_orin(?:[-:].*)?$/i.test(artifact?.image?.trim() ?? '') && Boolean(artifact?.url?.trim()) )) ?.image ?.trim() if (stack !== 'autoware' && image === slamRosBridgeImage && remoteJetsonBridgeImage) { image = remoteJetsonBridgeImage } const imageArtifact = environmentProfile?.runtimeImages?.find((artifact) => artifact?.image?.trim() === image) if (imageArtifact?.url?.trim()) { await runSsh(target, [ 'set -e', `IMAGE=${quoteShellSingle(image)}`, `URL=${quoteShellSingle(imageArtifact.url.trim())}`, `FILE_NAME=${quoteShellSingle(safeArtifactFileName(imageArtifact.url.trim(), imageArtifact.fileName))}`, `EXPECTED_SHA256=${quoteShellSingle(imageArtifact.checksumSha256?.trim().toLowerCase() ?? '')}`, 'if docker image inspect "$IMAGE" >/dev/null 2>&1; then exit 0; fi', 'CACHE="${XDG_CACHE_HOME:-$HOME/.cache}/autonomy-studio/artifacts"', 'mkdir -p "$CACHE"', 'ARCHIVE="$CACHE/$FILE_NAME"', 'if [ -f "$ARCHIVE" ] && [ -n "$EXPECTED_SHA256" ]; then', ' [ "$(sha256sum "$ARCHIVE" | awk \'{print $1}\')" = "$EXPECTED_SHA256" ] || rm -f "$ARCHIVE"', 'fi', 'if [ ! -f "$ARCHIVE" ]; then curl -fL "$URL" -o "$ARCHIVE"; fi', 'if [ -n "$EXPECTED_SHA256" ] && [ "$(sha256sum "$ARCHIVE" | awk \'{print $1}\')" != "$EXPECTED_SHA256" ]; then', ' echo "시각화 이미지 체크섬이 일치하지 않습니다: $FILE_NAME" >&2', ' exit 1', 'fi', 'case "$ARCHIVE" in', ' *.tar.zst|*.tzst) zstd -dc "$ARCHIVE" | docker load ;;', ' *) docker load -i "$ARCHIVE" ;;', 'esac', 'docker image inspect "$IMAGE" >/dev/null 2>&1 || { echo "시각화 이미지 로드에 실패했습니다: $IMAGE" >&2; exit 1; }', ].join('\n'), 900_000) } const command = bridgeContainerCommand(stack) await runSsh(target, [ 'set -e', `ROOT=${quoteShellSingle(root)}`, ...expandRemotePathVar('ROOT'), `FASTDDS_CONFIG_PATH=${quoteShellSingle(fastDdsConfigPath ?? '')}`, ...expandRemotePathVar('FASTDDS_CONFIG_PATH'), `ROS_DOMAIN_ID=${quoteShellSingle(rosDomainId)}`, `CONTAINER=${quoteShellSingle(containerName)}`, 'docker rm -f nav2-ros-bridge autoware-ros-bridge slam-ros-bridge >/dev/null 2>&1 || true', `docker image inspect ${quoteShellSingle(image)} >/dev/null 2>&1 || { echo "시각화 이미지가 설치되어 있지 않습니다: ${image}" >&2; exit 1; }`, [ 'docker run -d --name "$CONTAINER" --net=host', '-v /dev/shm:/dev/shm', '-v "$ROOT:/workspace"', ...(fastDdsConfigPath ? ['-v "$FASTDDS_CONFIG_PATH:/fastdds_profiles.xml:ro"', '-e FASTDDS_DEFAULT_PROFILES_FILE=/fastdds_profiles.xml', '-e FASTRTPS_DEFAULT_PROFILES_FILE=/fastdds_profiles.xml'] : []), '-e "ROS_DOMAIN_ID=$ROS_DOMAIN_ID"', '-e ROS_LOCALHOST_ONLY=0', `-e ROBOT_STACK=${quoteShellSingle(stack === 'autoware' ? 'autoware' : 'nav2')}`, '-e ODOM_TOPIC=/odom -e SCAN_TOPIC=/scan -e MAP_TOPIC=/map -e CMD_VEL_TOPIC=/cmd_vel -e NAVIGATE_TO_POSE_ACTION=/navigate_to_pose', ...(stack === 'slam3d' ? ['-e POINT_CLOUD_TOPIC=/cloud_map -e FALLBACK_POINT_CLOUD_TOPIC=/lidar_filtered -e ICP_ODOM_TOPIC=/icp_odom -e POINT_CLOUD_LIMIT=50000 -e RTABMAP_MAP_SAVE_ENABLED=true'] : []), bridgeEnvArgs, '-e WORLD_LAYOUT_PATH=/workspace/world-layout.json -e SAVED_MAPS_PATH=/workspace/maps -e RTABMAP_DATABASE_PATH=/workspace/maps/rtabmap-active.db', `${quoteShellSingle(image)} bash -lc ${quoteShellSingle(command)}`, ].join(' '), ].join('\n'), 30_000) return { ok: true, running: true, stack } } async function stopRemoteVisualizationBridge(target: RemoteWorkspaceTarget): Promise { await runSsh( target, 'docker rm -f nav2-ros-bridge autoware-ros-bridge slam-ros-bridge >/dev/null 2>&1 || true', 15_000, ) return { ok: true, running: false, stack: null } } function remotePathJoin(root: string, child: string): string { return `${root.replace(/\/+$/, '')}/${child.replace(/^\/+/, '')}` } function shellArgs(args: string[]): string { return args.map(quoteShellSingle).join(' ') } function expandRemotePathVar(varName: string): string[] { return [ `case "$${varName}" in`, ` "~") ${varName}="$HOME" ;;`, ` "~/"*) ${varName}="$HOME/\${${varName}:2}" ;;`, 'esac' ] } function remoteContainerKey(folderName?: string | null): string | null { if (!folderName) return null const dockerInfo = parseModuleDockerInfo(folderName) return dockerInfo?.containerKey ?? null } function decodeRemoteField(value?: string): string { if (!value) return '' try { return Buffer.from(value, 'base64').toString('utf8') } catch { return value } } function encodeBase64(value: string): string { return Buffer.from(value, 'utf8').toString('base64') } function normalizeGitRelativePath(filePath?: string | null): string { const normalized = filePath?.trim().replace(/\\/g, '/') ?? '' if (!normalized || normalized.startsWith('/') || normalized.split('/').some((part) => part === '..')) { throw new Error('Invalid git path') } return normalized } function buildRemoteModuleProbeScript(workspaceRoot: string, modules: CourseModuleInput[]): string { const moduleFolders = modules .filter((module) => module.folderName?.trim()) .map((module) => module.folderName.trim()) return [ 'set +e', `ROOT=${quoteShellSingle(workspaceRoot)}`, ...expandRemotePathVar('ROOT'), 'timeout 5s docker ps -a --format "DOCKER\\t{{.Names}}\\t{{.Status}}" 2>/dev/null || true', ...moduleFolders.map((folderName) => { const modulePath = remotePathJoin('$ROOT', folderName) return [ `FOLDER=${quoteShellSingle(folderName)}`, `MODULE_PATH="${modulePath}"`, 'if [ -d "$MODULE_PATH" ]; then', ' PARAMS=""', ' if [ -f "$MODULE_PATH/run.sh" ]; then', ' PARAMS=$(grep -E "^ROS_PARAM=" "$MODULE_PATH/run.sh" | head -n 1)', ' PARAMS=${PARAMS#ROS_PARAM=}', " PARAMS=${PARAMS#\\'}", " PARAMS=${PARAMS%\\'}", ' PARAMS=${PARAMS#\\"}', ' PARAMS=${PARAMS%\\"}', ' fi', ' printf "MODULE\\t%s\\t%s\\t%s\\n" "$FOLDER" "$MODULE_PATH" "$(printf "%s" "$PARAMS" | base64 -w0)"', 'else', ' printf "MODULE\\t%s\\t\\t\\n" "$FOLDER"', 'fi' ].join('\n') }) ].join('\n') } function mapGitStatusCode(code: string): GitChangedFile['status'] { if (code === 'A') return 'added' if (code === 'D') return 'deleted' if (code === 'R') return 'renamed' if (code === '?') return 'untracked' return 'modified' } function isGeneratedWorkspacePath(filePath: string): boolean { return ( filePath === '.gitignore' || filePath === studioGitMetadataFile || filePath === 'build' || filePath === 'install' || filePath === 'log' || filePath.startsWith('build/') || filePath.startsWith('install/') || filePath.startsWith('log/') || filePath.includes('/__pycache__/') || filePath.startsWith('__pycache__/') || /\.(pyc|pyo|pyd)$/i.test(filePath) || filePath.startsWith('.pytest_cache/') || filePath.startsWith('.mypy_cache/') || filePath.startsWith('.cache/') ) } function parseGitStatus(stdout: string): GitChangedFile[] { return stdout .split('\n') .map((line) => { if (!line.trim()) return null if (line.startsWith('## ')) return null const x = line[0] ?? ' ' const y = line[1] ?? ' ' const rawPath = line.slice(3).trim() const filePath = rawPath.includes(' -> ') ? rawPath.split(' -> ').at(-1)?.trim() ?? rawPath : rawPath if (isGeneratedWorkspacePath(filePath)) return null const xStatus = x.trim() const yStatus = y.trim() const statusCode = xStatus === '?' && yStatus === '?' ? '?' : xStatus || yStatus || 'M' return { path: filePath, status: mapGitStatusCode(statusCode), staged: xStatus !== '' && xStatus !== '?', unstaged: yStatus !== '' } }) .filter((file): file is GitChangedFile => Boolean(file)) } function parseGitBranch(stdout: string): { branch: string | null; ahead: number; behind: number } { const firstLine = stdout.split('\n').find((line) => line.startsWith('## ')) ?? '' const unbornBranchMatch = firstLine.match(/^##\s+No commits yet on\s+(.+)$/) if (unbornBranchMatch?.[1]) { return { branch: unbornBranchMatch[1].trim(), ahead: 0, behind: 0 } } const branchMatch = firstLine.match(/^##\s+([^\s.]+|HEAD)(?:\.\.\.[^\s]+)?(?:\s+\[(.+)\])?/) const statusText = branchMatch?.[2] ?? '' const ahead = Number(statusText.match(/ahead\s+(\d+)/)?.[1] ?? 0) const behind = Number(statusText.match(/behind\s+(\d+)/)?.[1] ?? 0) return { branch: branchMatch?.[1] && branchMatch[1] !== 'HEAD' ? branchMatch[1] : null, ahead, behind } } function parseGitCommits(stdout: string): GitCommitInfo[] { return stdout .split('\n') .map((line) => { if (!line.trim()) return null const [hash = '', shortHash = '', author = '', date = '', ...messageParts] = line.split('\x1f') return { hash, shortHash, author, date, message: messageParts.join('\x1f') } }) .filter((commit): commit is GitCommitInfo => Boolean(commit?.hash)) } async function ensureWorkspaceGitIgnore(moduleRoot: string): Promise { const gitIgnorePath = resolve(moduleRoot, '.gitignore') const currentContent = await readFile(gitIgnorePath, 'utf8').catch(() => '') const currentLines = new Set(currentContent.split(/\r?\n/)) const missingLines = workspaceGitIgnoreLines.filter((line) => line && !currentLines.has(line)) if (!missingLines.length) return const prefix = currentContent.trimEnd() const nextContent = `${prefix}${prefix ? '\n\n' : ''}${workspaceGitIgnoreLines.join('\n')}\n` await writeFile(gitIgnorePath, nextContent, 'utf8') } async function readStudioGitMetadata(moduleRoot: string): Promise { const metadataPath = resolve(moduleRoot, studioGitMetadataFile) try { return JSON.parse(await readFile(metadataPath, 'utf8')) as StudioGitMetadata } catch { return {} } } async function writeStudioGitMetadata(moduleRoot: string, metadata: StudioGitMetadata): Promise { await writeFile(resolve(moduleRoot, studioGitMetadataFile), `${JSON.stringify(metadata, null, 2)}\n`, 'utf8') } async function readStudioGitBaseCommit(moduleRoot: string): Promise { const metadata = await readStudioGitMetadata(moduleRoot) return metadata.baseCommit?.trim() || null } async function writeStudioGitBaseCommit(moduleRoot: string, baseCommit: string): Promise { const metadata = await readStudioGitMetadata(moduleRoot) await writeStudioGitMetadata(moduleRoot, { ...metadata, baseCommit }) } async function appendStudioGitSubmission(moduleRoot: string, submission: StudioGitSubmission): Promise { const metadata = await readStudioGitMetadata(moduleRoot) await writeStudioGitMetadata(moduleRoot, { ...metadata, submissions: [submission, ...(metadata.submissions ?? [])].slice(0, 100) }) } function submissionFromCommit(moduleName: string, commit: GitCommitInfo): StudioGitSubmission | null { // Older submissions were recorded only in local metadata. Fall back to the // commit subject so a restarted or restored workspace still shows them. if (!/(?:^|\s)제출$/.test(commit.message.trim())) return null return { id: `commit-${commit.hash}`, submittedAt: commit.date, moduleName, commit, } } function mergeStudioGitSubmissions(submissions: StudioGitSubmission[]): StudioGitSubmission[] { const unique = new Map() for (const submission of submissions) { const key = `${submission.moduleName}:${submission.commit.hash}` if (!unique.has(key)) unique.set(key, submission) } return [...unique.values()].sort((left, right) => Date.parse(right.submittedAt) - Date.parse(left.submittedAt)) } async function readWorkspaceSubmissionCommits(moduleRoot: string, moduleName: string): Promise { const output = await runCommandOutput( 'git', ['log', '--max-count=100', '--pretty=format:%H%x1f%h%x1f%an%x1f%ad%x1f%s', '--date=iso-strict'], moduleRoot, undefined, 8000, ).catch(() => '') return parseGitCommits(output) .map((commit) => submissionFromCommit(moduleName, commit)) .filter((submission): submission is StudioGitSubmission => Boolean(submission)) } async function inferStudioGitBaseCommit(moduleRoot: string): Promise { const explicitBaseCommit = await readStudioGitBaseCommit(moduleRoot) if (explicitBaseCommit) return explicitBaseCommit const firstCommit = await runCommandOutput('git', ['rev-list', '--max-parents=0', 'HEAD'], moduleRoot, undefined, 8000) .then((output) => output.split('\n')[0]?.trim() || null) .catch(() => null) if (firstCommit) await writeStudioGitBaseCommit(moduleRoot, firstCommit).catch(() => undefined) return firstCommit } async function resolveGitModuleRoot(moduleName: string, workspaceRoot?: string | null, folderName?: string | null): Promise { const moduleRoot = await findWorkspaceModuleRoot(moduleName, workspaceRoot, folderName) if (!moduleRoot) throw new Error('설치된 모듈을 찾을 수 없습니다.') const isRepository = await runCommandOutput('git', ['rev-parse', '--is-inside-work-tree'], moduleRoot, undefined, 8000) .then((output) => output.trim() === 'true') .catch(() => false) if (!isRepository) throw new Error('Git 저장소가 아닙니다.') await ensureWorkspaceGitIgnore(moduleRoot) return moduleRoot } async function resolveRemoteGitModuleRoot( targetInput: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, ): Promise<{ target: ReturnType; moduleRoot: string }> { const target = normalizeRemoteTarget(targetInput) const targetFolderName = folderName?.trim() || moduleName const cacheKey = `${target.user}@${target.host}:${target.port}:${target.workspaceRoot}:${targetFolderName}` const cachedModuleRoot = remoteGitModuleRoots.get(cacheKey) if (cachedModuleRoot) return { target, moduleRoot: cachedModuleRoot } const output = await runSshOutput(target, [ 'set -e', `ROOT=${quoteShellSingle(target.workspaceRoot)}`, ...expandRemotePathVar('ROOT'), `FOLDER=${quoteShellSingle(targetFolderName)}`, 'TARGET="$ROOT/$FOLDER"', 'test -d "$TARGET"', 'test "$(git -C "$TARGET" rev-parse --is-inside-work-tree 2>/dev/null)" = "true"', 'printf "%s" "$TARGET"' ].join('\n'), 8000) const moduleRoot = output.trim() if (!moduleRoot) throw new Error('원격 Git 저장소를 찾을 수 없습니다.') remoteGitModuleRoots.set(cacheKey, moduleRoot) return { target, moduleRoot } } async function readGitFileDiff(moduleRoot: string, filePath?: string | null): Promise { const normalizedPath = filePath?.trim() if (!normalizedPath) return '' const trackedDiff = await runCommandOutput('git', ['diff', '--no-ext-diff', '--', normalizedPath], moduleRoot, undefined, 8000).catch(() => '') if (trackedDiff.trim()) return trackedDiff const stagedDiff = await runCommandOutput('git', ['diff', '--cached', '--no-ext-diff', '--', normalizedPath], moduleRoot, undefined, 8000).catch(() => '') if (stagedDiff.trim()) return stagedDiff const absolutePath = resolve(moduleRoot, normalizedPath) if (!absolutePath.startsWith(moduleRoot) || !existsSync(absolutePath)) return '' const content = await readFile(absolutePath, 'utf8').catch(() => '') if (!content) return '' const lines = content.split('\n') return [ `diff --git a/${normalizedPath} b/${normalizedPath}`, 'new file mode 100644', 'index 0000000..0000000', '--- /dev/null', `+++ b/${normalizedPath}`, `@@ -0,0 +1,${lines.length} @@`, ...lines.map((line) => `+${line}`) ].join('\n') } async function readRemoteGitFileDiff(target: RemoteWorkspaceTarget, moduleRoot: string, filePath?: string | null): Promise { const normalizedPath = filePath?.trim() if (!normalizedPath) return '' const script = [ 'set +e', `TARGET=${quoteShellSingle(moduleRoot)}`, `SELECTED=${quoteShellSingle(normalizedPath)}`, 'DIFF="$(git -C "$TARGET" diff --no-ext-diff -- "$SELECTED" 2>/dev/null)"', 'if [ -z "$DIFF" ]; then', ' DIFF="$(git -C "$TARGET" diff --cached --no-ext-diff -- "$SELECTED" 2>/dev/null)"', 'fi', 'if [ -z "$DIFF" ] && [ -f "$TARGET/$SELECTED" ]; then', ' DIFF="$(git -C "$TARGET" diff --no-ext-diff --no-index /dev/null "$TARGET/$SELECTED" 2>/dev/null)"', 'fi', 'printf "%s" "$DIFF" | base64 -w0' ].join('\n') const output = await runSshOutput(target, script, 10000).catch(() => '') return decodeRemoteField(output.trim()) } async function readWorkspaceGitStatus( moduleName: string, workspaceRoot?: string | null, folderName?: string | null, selectedFilePath?: string | null ): Promise { try { const moduleRoot = await resolveGitModuleRoot(moduleName, workspaceRoot, folderName) const statusOutput = await runCommandOutput('git', ['status', '--porcelain=v1', '-b', '-uall', ...workspaceGitPathspec], moduleRoot, undefined, 8000) const { branch, ahead, behind } = parseGitBranch(statusOutput) const files = parseGitStatus(statusOutput) const nextSelectedPath = selectedFilePath && files.some((file) => file.path === selectedFilePath) ? selectedFilePath : files[0]?.path ?? null const baseCommit = await inferStudioGitBaseCommit(moduleRoot) const logRange = baseCommit ? `${baseCommit}..HEAD` : 'HEAD' const [diff, commitOutput] = await Promise.all([ readGitFileDiff(moduleRoot, nextSelectedPath), runCommandOutput('git', ['log', '--max-count=20', '--pretty=format:%H%x1f%h%x1f%an%x1f%ad%x1f%s', '--date=iso-strict', logRange], moduleRoot, undefined, 8000).catch(() => '') ]) return { ok: true, branch, ahead, behind, files, selectedFilePath: nextSelectedPath, diff, commits: parseGitCommits(commitOutput) } } catch (error) { return { ok: false, branch: null, ahead: 0, behind: 0, files: [], selectedFilePath: null, diff: '', commits: [], message: error instanceof Error ? error.message : String(error) } } } async function readRemoteWorkspaceGitStatus( targetInput: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, selectedFilePath?: string | null ): Promise { try { const { target, moduleRoot } = await resolveRemoteGitModuleRoot(targetInput, moduleName, folderName) const statusArgs = shellArgs(['status', '--porcelain=v1', '-b', '-uall', ...workspaceGitPathspec]) const logArgs = shellArgs(['log', '--max-count=20', '--pretty=format:%H%x1f%h%x1f%an%x1f%ad%x1f%s', '--date=iso-strict']) const script = [ 'set +e', `TARGET=${quoteShellSingle(moduleRoot)}`, `META="$TARGET/${studioGitMetadataFile}"`, 'BASE="$(grep -m1 \'"baseCommit"\' "$META" 2>/dev/null | sed -E \'s/.*"baseCommit"[[:space:]]*:[[:space:]]*"([^"]+)".*/\\1/\' || true)"', `printf "STATUS\\t"`, `git -C "$TARGET" ${statusArgs} 2>/dev/null | base64 -w0`, 'printf "\\nLOG\\t"', `if [ -n "$BASE" ]; then git -C "$TARGET" ${logArgs} "$BASE..HEAD" 2>/dev/null | base64 -w0; else git -C "$TARGET" ${logArgs} HEAD 2>/dev/null | base64 -w0; fi`, 'printf "\\n"' ].join('\n') const output = await runSshOutput(target, script, 12000) const sections = new Map() output.split('\n').forEach((line) => { const [key, value = ''] = line.split('\t') if (key) sections.set(key, decodeRemoteField(value)) }) const statusOutput = sections.get('STATUS') ?? '' const { branch, ahead, behind } = parseGitBranch(statusOutput) const files = parseGitStatus(statusOutput) const nextSelectedPath = selectedFilePath && files.some((file) => file.path === selectedFilePath) ? selectedFilePath : files[0]?.path ?? null const diff = await readRemoteGitFileDiff(target, moduleRoot, nextSelectedPath) return { ok: true, branch, ahead, behind, files, selectedFilePath: nextSelectedPath, diff, commits: parseGitCommits(sections.get('LOG') ?? '') } } catch (error) { return { ok: false, branch: null, ahead: 0, behind: 0, files: [], selectedFilePath: null, diff: '', commits: [], message: error instanceof Error ? error.message : String(error) } } } async function submitWorkspaceGitChanges( moduleName: string, workspaceRoot?: string | null, folderName?: string | null, message?: string | null, authorName?: string | null, authorEmail?: string | null, submissionId?: string | null, repositoryUrl?: string | null, ): Promise<{ ok: boolean; message: string; commit?: GitCommitInfo }> { try { const moduleRoot = await resolveGitModuleRoot(moduleName, workspaceRoot, folderName) const remoteUrl = repositoryUrl?.trim() if (remoteUrl) { const remotes = (await runCommandOutput('git', ['remote'], moduleRoot, undefined, 8000)) .split('\n') .map((item) => item.trim()) if (remotes.includes('origin')) { await runCommand('git', ['remote', 'set-url', 'origin', remoteUrl], moduleRoot, undefined, 8000) } else { await runCommand('git', ['remote', 'add', 'origin', remoteUrl], moduleRoot, undefined, 8000) } } const statusOutput = await runCommandOutput('git', ['status', '--porcelain=v1', '-uall', ...workspaceGitPathspec], moduleRoot, undefined, 8000) const changedFiles = parseGitStatus(statusOutput) if (!changedFiles.length) return { ok: false, message: '제출할 변경사항이 없습니다.' } const commitMessage = message?.trim() || `${moduleName} 제출` const name = authorName?.trim() || process.env.STUDIO_GIT_AUTHOR_NAME?.trim() || 'Autonomy Studio LMS Bot' const email = authorEmail?.trim() || process.env.STUDIO_GIT_AUTHOR_EMAIL?.trim() || 'lms-bot@futuredrive.net' await runCommand('git', ['add', '-A', '--', ...changedFiles.map((file) => file.path)], moduleRoot, undefined, 15000) await runCommand('git', ['-c', `user.name=${name}`, '-c', `user.email=${email}`, 'commit', '-m', commitMessage], moduleRoot, undefined, 30000) await runCommand('git', ['push', '-u', 'origin', 'HEAD'], moduleRoot, undefined, 120000) const commitOutput = await runCommandOutput('git', ['log', '-1', '--pretty=format:%H%x1f%h%x1f%an%x1f%ad%x1f%s', '--date=iso-strict'], moduleRoot, undefined, 8000) const commit = parseGitCommits(commitOutput)[0] if (commit && submissionId?.trim()) { await appendStudioGitSubmission(moduleRoot, { id: submissionId.trim(), submittedAt: new Date().toISOString(), moduleName, commit }).catch(() => undefined) } return { ok: true, message: '제출되었습니다.', commit } } catch (error) { return { ok: false, message: error instanceof Error ? error.message : String(error) } } } async function publishWorkspaceTeamGitChanges( moduleName: string, workspaceRoot?: string | null, folderName?: string | null, message?: string | null, authorName?: string | null, authorEmail?: string | null, ): Promise<{ ok: boolean; message: string; commit?: GitCommitInfo }> { try { const moduleRoot = await resolveGitModuleRoot(moduleName, workspaceRoot, folderName) const statusOutput = await runCommandOutput('git', ['status', '--porcelain=v1', '-uall', ...workspaceGitPathspec], moduleRoot, undefined, 8000) const changedFiles = parseGitStatus(statusOutput) if (!changedFiles.length) return { ok: false, message: '팀에 반영할 변경사항이 없습니다.' } const branch = (await runCommandOutput('git', ['branch', '--show-current'], moduleRoot, undefined, 8000)).trim() if (!branch) throw new Error('현재 Git 브랜치를 확인하지 못했습니다.') const remote = (await runCommandOutput('git', ['remote'], moduleRoot, undefined, 8000)) .split('\n') .map((item) => item.trim()) .includes('student') ? 'student' : 'origin' const remoteBranch = remote === 'student' ? 'main' : branch const commitMessage = message?.trim() || `${moduleName} 팀 반영` const name = authorName?.trim() || process.env.STUDIO_GIT_AUTHOR_NAME?.trim() || 'Autonomy Studio LMS' const email = authorEmail?.trim() || process.env.STUDIO_GIT_AUTHOR_EMAIL?.trim() || 'lms@futuredrive.net' await runCommand('git', ['add', '-A', '--', ...changedFiles.map((file) => file.path)], moduleRoot, undefined, 15000) await runCommand('git', ['-c', `user.name=${name}`, '-c', `user.email=${email}`, 'commit', '-m', commitMessage], moduleRoot, undefined, 30000) await runCommand('git', ['fetch', remote, '--prune'], moduleRoot, undefined, 60000) await runCommand('git', ['rebase', `${remote}/${remoteBranch}`], moduleRoot, undefined, 60000) await runCommand('git', ['push', '-u', remote, `HEAD:${remoteBranch}`], moduleRoot, undefined, 120000) const commitOutput = await runCommandOutput('git', ['log', '-1', '--pretty=format:%H%x1f%h%x1f%an%x1f%ad%x1f%s', '--date=iso-strict'], moduleRoot, undefined, 8000) return { ok: true, message: '팀 저장소에 반영했습니다.', commit: parseGitCommits(commitOutput)[0] } } catch (error) { return { ok: false, message: error instanceof Error ? error.message : String(error) } } } async function pullWorkspaceTeamGitChanges( moduleName: string, workspaceRoot?: string | null, folderName?: string | null, ): Promise<{ ok: boolean; message: string }> { try { const moduleRoot = await resolveGitModuleRoot(moduleName, workspaceRoot, folderName) const statusOutput = await runCommandOutput('git', ['status', '--porcelain=v1', '-uall'], moduleRoot, undefined, 8000) if (statusOutput.trim()) { throw new Error('로컬 변경사항이 있습니다. 먼저 팀에 반영하거나 변경사항을 되돌린 뒤 다시 시도하세요.') } const branch = (await runCommandOutput('git', ['branch', '--show-current'], moduleRoot, undefined, 8000)).trim() if (!branch) throw new Error('현재 Git 브랜치를 확인하지 못했습니다.') const remote = (await runCommandOutput('git', ['remote'], moduleRoot, undefined, 8000)) .split('\n') .map((item) => item.trim()) .includes('student') ? 'student' : 'origin' const remoteBranch = remote === 'student' ? 'main' : branch await runCommand('git', ['fetch', remote, '--prune'], moduleRoot, undefined, 60000) await runCommand('git', ['rebase', `${remote}/${remoteBranch}`], moduleRoot, undefined, 60000) return { ok: true, message: '팀 변경사항을 받았습니다.' } } catch (error) { return { ok: false, message: error instanceof Error ? error.message : String(error) } } } async function restoreWorkspaceGitFile( moduleName: string, workspaceRoot?: string | null, folderName?: string | null, filePath?: string | null, status?: GitChangedFile['status'] | null ): Promise<{ ok: boolean; message: string }> { try { const moduleRoot = await resolveGitModuleRoot(moduleName, workspaceRoot, folderName) const normalizedPath = normalizeGitRelativePath(filePath) if (status === 'untracked') { const absolutePath = resolve(moduleRoot, normalizedPath) const relativeToRoot = relative(moduleRoot, absolutePath) if (relativeToRoot.startsWith('..') || relativeToRoot === '..' || resolve(relativeToRoot) === relativeToRoot) { throw new Error('Invalid git path') } await rm(absolutePath, { recursive: true, force: true }) } else { await runCommand('git', ['restore', '--staged', '--worktree', '--', normalizedPath], moduleRoot, undefined, 15000) } return { ok: true, message: '변경사항을 되돌렸습니다.' } } catch (error) { return { ok: false, message: error instanceof Error ? error.message : String(error) } } } async function readRemoteStudioGitMetadata(target: RemoteWorkspaceTarget, moduleRoot: string): Promise { const output = await runSshOutput( target, `if [ -f ${quoteShellSingle(`${moduleRoot}/${studioGitMetadataFile}`)} ]; then cat ${quoteShellSingle(`${moduleRoot}/${studioGitMetadataFile}`)} | base64 -w0; fi`, 8000 ).catch(() => '') if (!output.trim()) return {} try { return JSON.parse(decodeRemoteField(output.trim())) as StudioGitMetadata } catch { return {} } } async function writeRemoteStudioGitMetadata(target: RemoteWorkspaceTarget, moduleRoot: string, metadata: StudioGitMetadata): Promise { const encoded = encodeBase64(`${JSON.stringify(metadata, null, 2)}\n`) await runSsh( target, `printf %s ${quoteShellSingle(encoded)} | base64 -d > ${quoteShellSingle(`${moduleRoot}/${studioGitMetadataFile}`)}`, 8000 ) } async function appendRemoteStudioGitSubmission(target: RemoteWorkspaceTarget, moduleRoot: string, submission: StudioGitSubmission): Promise { const metadata = await readRemoteStudioGitMetadata(target, moduleRoot) await writeRemoteStudioGitMetadata(target, moduleRoot, { ...metadata, submissions: [submission, ...(metadata.submissions ?? [])].slice(0, 100) }) } async function submitRemoteWorkspaceGitChanges( targetInput: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, message?: string | null, authorName?: string | null, authorEmail?: string | null, submissionId?: string | null, repositoryUrl?: string | null, ): Promise<{ ok: boolean; message: string; commit?: GitCommitInfo }> { try { const { target, moduleRoot } = await resolveRemoteGitModuleRoot(targetInput, moduleName, folderName) const status = await readRemoteWorkspaceGitStatus(target, moduleName, folderName, null) const changedFiles = status.files if (!changedFiles.length) return { ok: false, message: '제출할 변경사항이 없습니다.' } const commitMessage = message?.trim() || `${moduleName} 제출` const name = authorName?.trim() || process.env.STUDIO_GIT_AUTHOR_NAME?.trim() || 'Autonomy Studio LMS Bot' const email = authorEmail?.trim() || process.env.STUDIO_GIT_AUTHOR_EMAIL?.trim() || 'lms-bot@futuredrive.net' const addFiles = changedFiles.map((file) => file.path) const script = [ 'set -e', `TARGET=${quoteShellSingle(moduleRoot)}`, ...(repositoryUrl?.trim() ? [ `REPOSITORY_URL=${quoteShellSingle(repositoryUrl.trim())}`, 'if git -C "$TARGET" remote get-url origin >/dev/null 2>&1; then git -C "$TARGET" remote set-url origin "$REPOSITORY_URL"; else git -C "$TARGET" remote add origin "$REPOSITORY_URL"; fi', ] : []), `git -C "$TARGET" add -A -- ${shellArgs(addFiles)}`, `git -C "$TARGET" -c ${quoteShellSingle(`user.name=${name}`)} -c ${quoteShellSingle(`user.email=${email}`)} commit -m ${quoteShellSingle(commitMessage)}`, 'PUSH_REMOTE="$(git -C "$TARGET" remote get-url student >/dev/null 2>&1 && printf student || printf origin)"', 'git -C "$TARGET" push -u "$PUSH_REMOTE" HEAD:main', `git -C "$TARGET" log -1 --pretty=format:%H%x1f%h%x1f%an%x1f%ad%x1f%s --date=iso-strict | base64 -w0` ].join('\n') const commitOutput = decodeRemoteField((await runSshOutput(target, script, 150000)).trim()) const commit = parseGitCommits(commitOutput)[0] if (commit && submissionId?.trim()) { await appendRemoteStudioGitSubmission(target, moduleRoot, { id: submissionId.trim(), submittedAt: new Date().toISOString(), moduleName, commit }).catch(() => undefined) } return { ok: true, message: '제출되었습니다.', commit } } catch (error) { return { ok: false, message: error instanceof Error ? error.message : String(error) } } } async function publishRemoteWorkspaceTeamGitChanges( targetInput: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, message?: string | null, authorName?: string | null, authorEmail?: string | null, ): Promise<{ ok: boolean; message: string; commit?: GitCommitInfo }> { try { const { target, moduleRoot } = await resolveRemoteGitModuleRoot(targetInput, moduleName, folderName) const commitMessage = message?.trim() || `${moduleName} 팀 반영` const name = authorName?.trim() || process.env.STUDIO_GIT_AUTHOR_NAME?.trim() || 'Autonomy Studio LMS' const email = authorEmail?.trim() || process.env.STUDIO_GIT_AUTHOR_EMAIL?.trim() || 'lms@futuredrive.net' const script = [ 'set -e', `TARGET=${quoteShellSingle(moduleRoot)}`, `MESSAGE=${quoteShellSingle(commitMessage)}`, `AUTHOR_NAME=${quoteShellSingle(name)}`, `AUTHOR_EMAIL=${quoteShellSingle(email)}`, `STATUS="$(git -C "$TARGET" status --porcelain=v1 -uall ${shellArgs(workspaceGitPathspec)})"`, 'if [ -z "$STATUS" ]; then echo "NO_CHANGES"; exit 0; fi', 'BRANCH="$(git -C "$TARGET" branch --show-current)"', 'test -n "$BRANCH"', 'REMOTE="$(git -C "$TARGET" remote get-url student >/dev/null 2>&1 && printf student || printf origin)"', 'REMOTE_BRANCH="$BRANCH"', 'if [ "$REMOTE" = "student" ]; then REMOTE_BRANCH="main"; fi', `git -C "$TARGET" add -A -- ${shellArgs(workspaceGitPathspec)}`, 'git -C "$TARGET" -c "user.name=$AUTHOR_NAME" -c "user.email=$AUTHOR_EMAIL" commit -m "$MESSAGE"', 'git -C "$TARGET" fetch "$REMOTE" --prune', 'git -C "$TARGET" rebase "$REMOTE/$REMOTE_BRANCH"', 'git -C "$TARGET" push -u "$REMOTE" "HEAD:$REMOTE_BRANCH"', 'git -C "$TARGET" log -1 --pretty=format:%H%x1f%h%x1f%an%x1f%ad%x1f%s --date=iso-strict | base64 -w0' ].join('\n') const output = (await runSshOutput(target, script, 180000)).trim() if (output === 'NO_CHANGES') return { ok: false, message: '팀에 반영할 변경사항이 없습니다.' } return { ok: true, message: '팀 저장소에 반영했습니다.', commit: parseGitCommits(decodeRemoteField(output))[0] } } catch (error) { return { ok: false, message: error instanceof Error ? error.message : String(error) } } } async function pullRemoteWorkspaceTeamGitChanges( targetInput: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, ): Promise<{ ok: boolean; message: string }> { try { const { target, moduleRoot } = await resolveRemoteGitModuleRoot(targetInput, moduleName, folderName) const script = [ 'set -e', `TARGET=${quoteShellSingle(moduleRoot)}`, 'if [ -n "$(git -C "$TARGET" status --porcelain=v1 -uall)" ]; then', ' echo "LOCAL_CHANGES"', ' exit 0', 'fi', 'BRANCH="$(git -C "$TARGET" branch --show-current)"', 'test -n "$BRANCH"', 'REMOTE="$(git -C "$TARGET" remote get-url student >/dev/null 2>&1 && printf student || printf origin)"', 'REMOTE_BRANCH="$BRANCH"', 'if [ "$REMOTE" = "student" ]; then REMOTE_BRANCH="main"; fi', 'git -C "$TARGET" fetch "$REMOTE" --prune', 'git -C "$TARGET" rebase "$REMOTE/$REMOTE_BRANCH"' ].join('\n') const output = (await runSshOutput(target, script, 90000)).trim() if (output === 'LOCAL_CHANGES') { return { ok: false, message: '로컬 변경사항이 있습니다. 먼저 팀에 반영하거나 변경사항을 되돌린 뒤 다시 시도하세요.' } } return { ok: true, message: '팀 변경사항을 받았습니다.' } } catch (error) { return { ok: false, message: error instanceof Error ? error.message : String(error) } } } async function restoreRemoteWorkspaceGitFile( targetInput: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, filePath?: string | null, status?: GitChangedFile['status'] | null ): Promise<{ ok: boolean; message: string }> { try { const { target, moduleRoot } = await resolveRemoteGitModuleRoot(targetInput, moduleName, folderName) const normalizedPath = normalizeGitRelativePath(filePath) const absolutePath = remotePathJoin(moduleRoot, normalizedPath) const command = status === 'untracked' ? `rm -rf ${quoteShellSingle(absolutePath)}` : `git -C ${quoteShellSingle(moduleRoot)} restore --staged --worktree -- ${quoteShellSingle(normalizedPath)}` await runSsh(target, command, 15000) return { ok: true, message: '변경사항을 되돌렸습니다.' } } catch (error) { return { ok: false, message: error instanceof Error ? error.message : String(error) } } } async function readWorkspaceGitSubmissions( modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string | null ): Promise<{ ok: boolean; submissions: StudioGitSubmission[]; message?: string }> { try { const submissions: StudioGitSubmission[] = [] for (const module of modules) { const moduleRoot = await resolveGitModuleRoot(module.moduleName, workspaceRoot, module.folderName).catch(() => null) if (!moduleRoot) continue const metadata = await readStudioGitMetadata(moduleRoot) submissions.push(...(metadata.submissions ?? [])) submissions.push(...await readWorkspaceSubmissionCommits(moduleRoot, module.moduleName)) } return { ok: true, submissions: mergeStudioGitSubmissions(submissions) } } catch (error) { return { ok: false, submissions: [], message: error instanceof Error ? error.message : String(error) } } } async function readRemoteWorkspaceGitSubmissions( targetInput: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>, ): Promise<{ ok: boolean; submissions: StudioGitSubmission[]; message?: string }> { try { const target = normalizeRemoteTarget(targetInput) const submissions: StudioGitSubmission[] = [] for (const module of modules) { const resolved = await resolveRemoteGitModuleRoot(target, module.moduleName, module.folderName).catch(() => null) if (!resolved) continue const metadata = await readRemoteStudioGitMetadata(target, resolved.moduleRoot) submissions.push(...(metadata.submissions ?? [])) const commitOutput = await runSshOutput(target, [ 'set -e', `TARGET=${quoteShellSingle(resolved.moduleRoot)}`, 'git -C "$TARGET" log --max-count=100 --pretty=format:%H%x1f%h%x1f%an%x1f%ad%x1f%s --date=iso-strict | base64 -w0', ].join('\n'), 10000).catch(() => '') submissions.push(...parseGitCommits(decodeRemoteField(commitOutput.trim())) .map((commit) => submissionFromCommit(module.moduleName, commit)) .filter((submission): submission is StudioGitSubmission => Boolean(submission))) } return { ok: true, submissions: mergeStudioGitSubmissions(submissions) } } catch (error) { return { ok: false, submissions: [], message: error instanceof Error ? error.message : String(error) } } } async function readWorkspaceGitCommitDiff( moduleName: string, workspaceRoot?: string | null, folderName?: string | null, commitHash?: string | null ): Promise<{ ok: boolean; diff: string; message?: string }> { try { const hash = commitHash?.trim() if (!hash) throw new Error('커밋 해시가 없습니다.') const moduleRoot = await resolveGitModuleRoot(moduleName, workspaceRoot, folderName) const diff = await runCommandOutput('git', ['show', '--format=', '--no-ext-diff', `${hash}^!`, ...workspaceGitPathspec], moduleRoot, undefined, 15000) return { ok: true, diff } } catch (error) { return { ok: false, diff: '', message: error instanceof Error ? error.message : String(error) } } } async function readRemoteWorkspaceGitCommitDiff( targetInput: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, commitHash?: string | null ): Promise<{ ok: boolean; diff: string; message?: string }> { try { const hash = commitHash?.trim() if (!hash) throw new Error('커밋 해시가 없습니다.') const { target, moduleRoot } = await resolveRemoteGitModuleRoot(targetInput, moduleName, folderName) const showArgs = shellArgs(['show', '--format=', '--no-ext-diff', `${hash}^!`, ...workspaceGitPathspec]) const output = await runSshOutput(target, `git -C ${quoteShellSingle(moduleRoot)} ${showArgs} | base64 -w0`, 20000) return { ok: true, diff: decodeRemoteField(output.trim()) } } catch (error) { return { ok: false, diff: '', message: error instanceof Error ? error.message : String(error) } } } async function readDockerStatuses(): Promise> { const statuses = new Map() const stdout = await runCommandOutput('docker', ['ps', '-a', '--format', '{{.Names}}\t{{.Status}}'], undefined, undefined, 5000) for (const line of stdout.split('\n')) { const trimmed = line.trim() if (!trimmed) continue const [name, status = ''] = trimmed.split('\t') statuses.set(name, { exists: true, running: /^Up\b/i.test(status), status }) } return statuses } async function readDockerStats(): Promise> { const stats = new Map() const stdout = await runCommandOutput('docker', ['stats', '--no-stream', '--format', '{{.Name}}\t{{.CPUPerc}}\t{{.MemPerc}}'], undefined, undefined, 8000) for (const line of stdout.split('\n')) { const trimmed = line.trim() if (!trimmed) continue const [name, cpuValue = '', memoryValue = ''] = trimmed.split('\t') const cpu = parseDockerPercent(cpuValue) const memory = parseDockerPercent(memoryValue) if (!name || cpu === null || memory === null) continue stats.set(name, { cpu, memory }) } return stats } function visualizationBridgeContainerName(stack: VisualizationStack): string { if (stack === 'autoware') return 'autoware-ros-bridge' if (stack === 'nav2' || stack === 'slam3d') return 'nav2-ros-bridge' return 'slam-ros-bridge' } function visualizationBridgeImageName(stack: VisualizationStack): string { if (stack === 'autoware') return autowareDockerImage if (stack === 'nav2') return slamRosBridgeImage return slamRosBridgeImage } function visualizationBridgeResourceContext(stack: VisualizationStack, bridge?: EnvironmentProfileConfig['bridge'] | null): string | null { if (bridge?.buildContext?.trim()) return bridge.buildContext.trim() if (stack === 'autoware') return 'resources/autoware-ros-bridge' if (stack === 'nav2') return 'resources/slam-ros-bridge' return 'resources/slam-ros-bridge' } function resolveVisualizationBridgeImage( stack: VisualizationStack, bridge?: EnvironmentProfileConfig['bridge'] | null, runtimeImages?: RuntimeImageArtifact[] | null, ): string { const configuredImage = bridge?.image?.trim() if (stack === 'autoware') { if (!configuredImage || configuredImage === autowareRosBridgeImage) { return runtimeImages?.find((image) => image?.image?.trim())?.image?.trim() || autowareDockerImage } return configuredImage } if (stack === 'slam') { if (!configuredImage || configuredImage === 'slam-ros-bridge' || configuredImage === 'slam-ros-bridge:latest' || configuredImage === slamRosBridgeImage) { return runtimeImages?.find((image) => image?.image?.trim())?.image?.trim() || slamSimulationRuntimeImage } return configuredImage } if (!configuredImage || configuredImage === 'slam-ros-bridge' || configuredImage === 'slam-ros-bridge:latest') { return visualizationBridgeImageName(stack) } return configuredImage } function bridgeContainerCommand(stack: VisualizationStack): string { const usesSlamBridge = stack === 'slam' || stack === 'slam3d' || stack === 'nav2' const setupWorkspace = stack === 'autoware' ? 'if [ -f /autoware_ws/install/setup.bash ]; then source /autoware_ws/install/setup.bash; fi' : '' const bridgePath = usesSlamBridge ? '/app/bridge.py' : '/ros_bridge/bridge.py' const requirementPath = usesSlamBridge ? '/app/requirements.txt' : '/ros_bridge/requirements.txt' return [ 'source /opt/ros/humble/setup.bash', setupWorkspace, `(python3 -c "import fastapi, uvicorn, websockets" || python3 -m pip install --no-cache-dir -r ${requirementPath})`, `python3 ${bridgePath}` ].filter(Boolean).join(' && ') } function normalizeVisualizationStack(value?: string | null): VisualizationStack { if (value === 'slam') return 'slam' if (value === 'slam3d') return 'slam3d' return value === 'nav2' || value === 'local' || value === 'platform' ? 'nav2' : 'autoware' } async function readContainerEnvironmentValue(containerName: string, key: string): Promise { try { const output = await runCommandOutput( 'docker', ['inspect', '--format', '{{range .Config.Env}}{{println .}}{{end}}', containerName], undefined, undefined, 5000, ) const value = output .split('\n') .find((line) => line.startsWith(`${key}=`)) ?.slice(key.length + 1) .trim() return value || null } catch { return null } } async function readContainerMountSource(containerName: string, destination: string): Promise { try { const output = await runCommandOutput( 'docker', ['inspect', '--format', `{{range .Mounts}}{{if eq .Destination ${JSON.stringify(destination)}}}{{.Source}}{{end}}{{end}}`, containerName], undefined, undefined, 5000, ) const source = output.trim() return source ? resolve(source) : null } catch { return null } } async function findPlanningWorkspacePath(workspaceRoot?: string | null): Promise { const resolvedWorkspaceRoot = expandUserPath(workspaceRoot) if (!resolvedWorkspaceRoot) return null try { const entries = await readdir(resolvedWorkspaceRoot, { withFileTypes: true }) const planning = entries .filter((entry) => entry.isDirectory()) .map((entry) => entry.name) .find((name) => /^\[furive-planning\].+_ws$/i.test(name) || /AW-Planning/i.test(name)) return planning ? resolve(resolvedWorkspaceRoot, planning) : null } catch { return null } } function resolveResourceContextPath(contextPath = 'resources/slam-ros-bridge'): string | null { const normalizedContextPath = contextPath.replace(/^\/+/, '') const candidates = [ join(process.resourcesPath, normalizedContextPath.replace(/^resources\//, '')), ...(app.isPackaged ? [] : [join(app.getAppPath(), normalizedContextPath)]), join(process.cwd(), normalizedContextPath) ] return candidates.find((candidate) => existsSync(join(candidate, 'Dockerfile'))) ?? null } async function resolveDockerResourceContextPath(contextPath = 'resources/slam-ros-bridge'): Promise { const sourcePath = resolveResourceContextPath(contextPath) if (!sourcePath) return null // Docker's daemon cannot access an AppImage FUSE mount under /tmp/.mount_*. // Stage the small bridge context in userData before using it as a build context or bind mount. if (!app.isPackaged || !sourcePath.startsWith(`${process.resourcesPath}/`)) return sourcePath const targetPath = join(app.getPath('userData'), 'runtime', 'docker-contexts', basename(sourcePath)) await rm(targetPath, { recursive: true, force: true }) await mkdir(dirname(targetPath), { recursive: true }) await cp(sourcePath, targetPath, { recursive: true, force: true }) return targetPath } async function ensureBridgeImage(image: string, buildContext?: string | null): Promise { if (!buildContext) return const contextPath = await resolveDockerResourceContextPath(buildContext) if (!contextPath) throw new Error(`브릿지 Docker context를 찾을 수 없습니다: ${buildContext}`) await runCommand('docker', ['build', '-t', image, contextPath], undefined, undefined, 120000) } function expandDockerVolume(volume: string): string { const separatorIndex = volume.indexOf(':') if (separatorIndex <= 0) return volume const hostPath = volume.slice(0, separatorIndex) const containerPath = volume.slice(separatorIndex + 1) return `${expandUserPath(hostPath) ?? hostPath}:${containerPath}` } function runtimeLaunchCommand(command: string, logFile: string, rosDomainId: string, env?: Record): string { const exports = Object.entries(env ?? {}).map(([key, value]) => `export ${key}=${JSON.stringify(value)}`) return [ 'source /opt/ros/humble/setup.bash', `export ROS_DOMAIN_ID=${rosDomainId}`, ...exports, `(${command}) > ${logFile} 2>&1` ].join('; ') } async function execRuntimeDetached(containerName: string, command: string, logFile: string, rosDomainId: string, env?: Record): Promise { await runCommand( 'docker', [ 'exec', '-d', containerName, 'bash', '-lc', runtimeLaunchCommand(command, logFile, rosDomainId, env) ], undefined, undefined, 15000 ) } async function ensureEnvironmentRuntime(profile: EnvironmentProfileConfig | null | undefined, rosDomainIdInput?: string | null): Promise { const rosDomainId = String(rosDomainIdInput ?? process.env.ROS_DOMAIN_ID ?? '77').trim() || '77' const display = process.env.DISPLAY?.trim() || ':1' const containers = profile?.runtime?.containers ?? [] const steps = profile?.runtime?.steps ?? [] if (!containers.length) return const statuses = await readDockerStatuses().catch(() => new Map()) await runCommand('xhost', ['+local:docker'], undefined, undefined, 5000).catch(() => undefined) for (const container of containers) { const name = container.name?.trim() const image = container.image?.trim() if (!name || !image || !isAllowedRuntimeContainerName(name)) throw new Error('환경 프로파일 컨테이너 설정이 올바르지 않습니다.') if (statuses.get(name)?.running) { await runCommand('docker', ['stop', name], undefined, undefined, 15000).catch(() => undefined) } else if (statuses.get(name)?.exists) { await runCommand('docker', ['rm', '-f', name], undefined, undefined, 10000).catch(() => undefined) } const args = ['run', '--rm', '-d', '--name', name] if (container.network) args.push('--network', container.network) if (container.privileged) args.push('--privileged') const env = { DISPLAY: display, ...container.env, ROS_DOMAIN_ID: rosDomainId } Object.entries(env).forEach(([key, value]) => args.push('-e', `${key}=${value}`)) ;(container.volumes ?? []).forEach((volume) => args.push('-v', expandDockerVolume(volume))) args.push(image, 'bash', '-lc', container.command?.trim() || 'tail -f /dev/null') await runCommand('docker', args, undefined, undefined, 30000) } for (const step of steps) { const containerName = step.container?.trim() const command = step.command?.trim() if (!containerName || !command || !isAllowedRuntimeContainerName(containerName)) continue if (step.delayMs && step.delayMs > 0) await new Promise((resolveTimer) => setTimeout(resolveTimer, step.delayMs)) await execRuntimeDetached(containerName, command, step.logFile?.trim() || '/tmp/autonomy-studio-step.log', rosDomainId, containers.find((container) => container.name === containerName)?.env) } } async function readVisualizationBridgeStatus(): Promise { try { const statuses = await readDockerStatuses().catch(() => new Map()) const autowareRunning = Boolean(statuses.get(visualizationBridgeContainerName('autoware'))?.running) const nav2Running = Boolean(statuses.get(visualizationBridgeContainerName('nav2'))?.running) const slamRunning = Boolean(statuses.get(visualizationBridgeContainerName('slam'))?.running) if (!autowareRunning && !nav2Running && !slamRunning) return { ok: true, running: false, stack: null } if (slamRunning || nav2Running) { const response = await fetch(`http://localhost:${rosBridgePort}/health`).catch(() => null) return { ok: Boolean(response?.ok), running: true, stack: slamRunning ? 'slam' : 'nav2', message: response?.ok ? undefined : '시각화 브릿지 상태를 확인할 수 없습니다.' } } const response = await fetch(`http://localhost:${rosBridgePort}/stack`).catch(() => null) const payload = response?.ok ? await response.json().catch(() => null) : null const stack = normalizeVisualizationStack(typeof payload?.stack === 'string' ? payload.stack : nav2Running ? 'nav2' : 'autoware') return { ok: true, running: true, stack } } catch (error) { const message = error instanceof Error ? error.message : String(error) writeRendererDiagnostic('visualization-start', message) return { ok: false, running: false, stack: null, message } } } async function waitForVisualizationBridgeReady(stack: VisualizationStack, bridgePort: number, timeoutMs = 20000): Promise { const endpoint = stack === 'slam' || stack === 'slam3d' || stack === 'nav2' ? 'health' : 'stack' const startedAt = Date.now() let lastMessage = '' while (Date.now() - startedAt < timeoutMs) { const response = await fetch(`http://localhost:${bridgePort}/${endpoint}`).catch((error) => { lastMessage = error instanceof Error ? error.message : String(error) return null }) if (response?.ok) return if (response) lastMessage = `HTTP ${response.status}` await new Promise((resolve) => setTimeout(resolve, 350)) } throw new Error(`시각화 시작 대기 시간이 초과되었습니다.${lastMessage ? ` (${lastMessage})` : ''}`) } function normalizeRuntimeTarget(target?: string | null): 'local' | 'remote-jetson' { return target === 'remote-jetson' ? 'remote-jetson' : 'local' } function envValue(key: string): string | null { const value = process.env[key]?.trim() return value ? value : null } function stackEnvPrefix(stack: VisualizationStack): string { return stack.toUpperCase() } function withRuntimeProfileDefaults( profile: EnvironmentProfileConfig | null | undefined, stack: VisualizationStack, ): EnvironmentProfileConfig | null | undefined { if (!profile) return profile const target = normalizeRuntimeTarget(profile.target) const prefix = target === 'remote-jetson' ? `JETSON_${stackEnvPrefix(stack)}` : `LOCAL_${stackEnvPrefix(stack)}` const bridgeImage = envValue(`${prefix}_BRIDGE_IMAGE`) ?? profile.bridge?.image const runtimeImage = envValue(`${prefix}_RUNTIME_IMAGE`) const agentUrl = target === 'remote-jetson' ? envValue(`${prefix}_AGENT_URL`) ?? envValue('JETSON_AGENT_URL') ?? profile.agent?.url : profile.agent?.url const agentToken = target === 'remote-jetson' ? envValue(`${prefix}_AGENT_TOKEN`) ?? envValue('JETSON_AGENT_TOKEN') ?? profile.agent?.token : profile.agent?.token return { ...profile, target, agent: agentUrl || agentToken ? { ...profile.agent, url: agentUrl ?? profile.agent?.url, token: agentToken ?? profile.agent?.token } : profile.agent, bridge: bridgeImage ? { ...profile.bridge, image: bridgeImage } : profile.bridge, runtime: runtimeImage ? { ...profile.runtime, containers: (profile.runtime?.containers ?? []).map((container, index) => ( index === 0 ? { ...container, image: runtimeImage } : container )) } : profile.runtime } } async function callRemoteVisualizationAgent( profile: EnvironmentProfileConfig, action: 'start' | 'stop' | 'status', payload: Record = {}, ): Promise { const agentUrl = profile.agent?.url?.trim().replace(/\/+$/, '') if (!agentUrl) throw new Error('원격 제어 Agent URL이 설정되지 않았습니다.') const headers: Record = { 'Content-Type': 'application/json' } const token = profile.agent?.token?.trim() if (token) headers.Authorization = `Bearer ${token}` const response = await fetch(`${agentUrl}/visualization/${action}`, { method: action === 'status' ? 'GET' : 'POST', headers, body: action === 'status' ? undefined : JSON.stringify({ profile, ...payload }) }) if (!response.ok) { const detail = await response.text().catch(() => '') throw new Error(`원격 Agent 요청 실패: HTTP ${response.status}${detail ? ` ${detail}` : ''}`) } const result = await response.json().catch(() => ({})) return { ok: result?.ok !== false, running: Boolean(result?.running ?? action === 'start'), stack: normalizeVisualizationStack(typeof result?.stack === 'string' ? result.stack : profile.type), message: typeof result?.message === 'string' ? result.message : undefined } } async function startVisualizationBridge( stackInput?: string | null, workspaceRoot?: string | null, rosDomainId?: string | null, environmentProfile?: EnvironmentProfileConfig | null, rosNetwork?: RosNetworkConfig | null, savedMapDatabasePath?: string | null, savedMapWorldLayoutPath?: string | null, ): Promise { const stack = normalizeVisualizationStack(stackInput ?? environmentProfile?.type?.toLowerCase()) const resolvedProfile = withRuntimeProfileDefaults(environmentProfile, stack) const target = normalizeRuntimeTarget(resolvedProfile?.target) const bridge = resolvedProfile?.bridge const containerName = bridge?.name?.trim() || visualizationBridgeContainerName(stack) const bridgeImage = resolveVisualizationBridgeImage(stack, bridge, resolvedProfile?.runtimeImages) const bridgePort = bridge?.port ?? rosBridgePort const usesSlamBridge = stack === 'slam' || stack === 'slam3d' || stack === 'nav2' const layoutWorkspaceRoot = usesSlamBridge ? expandUserPath(workspaceRoot) : null if (layoutWorkspaceRoot) await mkdir(resolve(layoutWorkspaceRoot, 'maps'), { recursive: true }) const savedMapWorldLayoutFileName = savedMapWorldLayoutPath ? safeSavedMapFileName(basename(savedMapWorldLayoutPath)) : null const requestedWorldLayoutPath = savedMapWorldLayoutFileName ? `/workspace/maps/${savedMapWorldLayoutFileName}` : '/workspace/world-layout.json' if (layoutWorkspaceRoot && !savedMapWorldLayoutFileName) { const customLayoutPath = worldLayoutPath(layoutWorkspaceRoot) try { const currentLayout = await readFile(customLayoutPath, 'utf8') const parsedLayout = JSON.parse(currentLayout) const normalizedLayout = normalizeCustomWorldLayout(parsedLayout) if (JSON.stringify(normalizedLayout) !== JSON.stringify(parsedLayout)) { await writeJsonAtomically(customLayoutPath, normalizedLayout) } } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error } } const requestedRosDomainId = String(rosDomainId ?? process.env.ROS_DOMAIN_ID ?? '0').trim() || '0' const requestedRosNetworkKey = rosNetworkEnabled(rosNetwork) ? `peer:${rosNetwork?.peer?.host?.trim() ?? ''}` : 'local' const otherContainerNames = (['autoware', 'nav2', 'slam'] as VisualizationStack[]) .filter((item) => item !== stack) .map(visualizationBridgeContainerName) try { if (target === 'remote-jetson' && resolvedProfile) { return await callRemoteVisualizationAgent(resolvedProfile, 'start', { stack, workspaceRoot, rosDomainId: requestedRosDomainId, rosNetwork, }) } const statuses = await readDockerStatuses().catch(() => new Map()) if (statuses.get(containerName)?.running) { const response = await fetch(`http://localhost:${bridgePort}/${usesSlamBridge ? 'health' : 'stack'}`).catch(() => null) const runningRosDomainId = await readContainerEnvironmentValue(containerName, 'ROS_DOMAIN_ID') const runningRosNetworkKey = await readContainerEnvironmentValue(containerName, 'AUTONOMY_STUDIO_ROS_NETWORK') const runningWorkspaceRoot = layoutWorkspaceRoot ? await readContainerMountSource(containerName, '/workspace') : null const runningWorldLayoutPath = layoutWorkspaceRoot ? await readContainerEnvironmentValue(containerName, 'WORLD_LAYOUT_PATH') : null if (response?.ok && (!runningRosDomainId || runningRosDomainId === requestedRosDomainId) && runningRosNetworkKey === requestedRosNetworkKey && (!layoutWorkspaceRoot || ( runningWorkspaceRoot === resolve(layoutWorkspaceRoot) && runningWorldLayoutPath === requestedWorldLayoutPath ))) { return readVisualizationBridgeStatus() } await runCommand('docker', ['rm', '-f', containerName], undefined, undefined, 10000).catch(() => undefined) } const runningOtherContainers = otherContainerNames.filter((name) => statuses.get(name)?.running) if (runningOtherContainers.length) { await runCommand('docker', ['stop', ...runningOtherContainers], undefined, undefined, 15000).catch(() => undefined) } if (statuses.get(containerName)?.exists) { await runCommand('docker', ['rm', '-f', containerName], undefined, undefined, 10000).catch(() => undefined) } await prepareRuntimeImages(resolvedProfile?.runtimeImages) if (resolvedProfile?.runtime?.containers?.length) { const runtimeProfile = { ...resolvedProfile, runtime: { ...resolvedProfile.runtime, containers: resolvedProfile.runtime.containers.filter((container) => container.name?.trim() !== containerName) } } await ensureEnvironmentRuntime(runtimeProfile, requestedRosDomainId) } const bridgeContextPath = await resolveDockerResourceContextPath(visualizationBridgeResourceContext(stack, bridge) ?? undefined) if (!bridgeContextPath) throw new Error(`브릿지 리소스를 찾을 수 없습니다: ${visualizationBridgeResourceContext(stack, bridge) ?? stack}`) const bridgeImageExists = await runCommand('docker', ['image', 'inspect', bridgeImage], undefined, undefined, 10000) .then(() => true) .catch(() => false) if (!bridgeImageExists && stack !== 'autoware' && stack !== 'slam') { await ensureBridgeImage(bridgeImage, visualizationBridgeResourceContext(stack, bridge)) } const args = [ 'run', '-d', '--name', containerName, '--net=host', '-v', '/dev/shm:/dev/shm', '-v', `${bridgeContextPath}:${usesSlamBridge ? '/app' : '/ros_bridge'}:ro`, '-e', `ROS_DOMAIN_ID=${requestedRosDomainId}`, '-e', `ROS_LOCALHOST_ONLY=${rosNetworkEnabled(rosNetwork) ? '0' : '1'}`, '-e', `AUTONOMY_STUDIO_ROS_NETWORK=${requestedRosNetworkKey}`, '-e', `ROBOT_STACK=${stack}`, '-e', 'RCUTILS_COLORIZED_OUTPUT=1', '-e', 'RCUTILS_LOGGING_BUFFERED_STREAM=0', '-e', 'PYTHONUNBUFFERED=1', ] if (layoutWorkspaceRoot) { args.push( '-v', `${layoutWorkspaceRoot}:/workspace`, '-e', `WORLD_LAYOUT_PATH=${requestedWorldLayoutPath}`, '-e', 'SAVED_MAPS_PATH=/workspace/maps', '-e', 'RTABMAP_DATABASE_PATH=/workspace/maps/rtabmap-active.db', ) if (stack === 'slam3d') args.push('-e', 'RTABMAP_MAP_SAVE_ENABLED=true') const savedMapFileName = savedMapDatabasePath ? safeSavedMapFileName(basename(savedMapDatabasePath)) : null if (savedMapFileName?.endsWith('.db') && existsSync(resolve(layoutWorkspaceRoot, 'maps', savedMapFileName))) { args.push('-e', `SAVED_MAP_DATABASE_PATH=/workspace/maps/${savedMapFileName}`) } } const fastDdsConfigPath = await prepareLocalRosNetworkConfig(rosNetwork, requestedRosDomainId) if (fastDdsConfigPath && existsSync(fastDdsConfigPath)) { args.push( '-v', `${fastDdsConfigPath}:/fastdds_profiles.xml:ro`, '-e', 'FASTDDS_DEFAULT_PROFILES_FILE=/fastdds_profiles.xml', '-e', 'FASTRTPS_DEFAULT_PROFILES_FILE=/fastdds_profiles.xml', ) } if (stack === 'autoware') { args.push('-e', 'MAP_POINT_STRIDE=8', '-e', 'MAP_POINT_LIMIT=80000') args.push('-e', 'AUTOWARE_ROUTE_INPUT=rviz_topic') const planningWorkspacePath = await findPlanningWorkspacePath(workspaceRoot) if (planningWorkspacePath) args.push('-v', `${planningWorkspacePath}:/autoware_ws:ro`) } const bridgeEnv = bridge?.env ?? (usesSlamBridge ? { ODOM_TOPIC: '/odom', SCAN_TOPIC: '/scan', MAP_TOPIC: '/map', CMD_VEL_TOPIC: '/cmd_vel', NAVIGATE_TO_POSE_ACTION: '/navigate_to_pose' } : {}) Object.entries(bridgeEnv).forEach(([key, value]) => args.push('-e', `${key}=${value}`)) if (usesSlamBridge) { args.push('-e', `PORT=${bridgePort}`) } args.push(bridgeImage) args.push('bash', '-lc', bridgeContainerCommand(stack)) await runCommand('docker', args, undefined, undefined, 15000) await waitForVisualizationBridgeReady(stack, bridgePort) return readVisualizationBridgeStatus() } catch (error) { return { ok: false, running: false, stack: null, message: error instanceof Error ? error.message : String(error) } } } function getEnvironmentProfileContainerNames(environmentProfile?: EnvironmentProfileConfig | null): string[] { const names = new Set() const profileType = environmentProfile?.type?.toLowerCase() const bridgeName = environmentProfile?.bridge?.name?.trim() if (bridgeName && isAllowedRuntimeContainerName(bridgeName)) names.add(bridgeName) ;(environmentProfile?.runtime?.containers ?? []).forEach((container) => { const name = container.name?.trim() if (name && isAllowedRuntimeContainerName(name)) names.add(name) }) if (profileType === 'slam') names.add(slamSimulationRuntimeContainerName) return Array.from(names) } async function stopVisualizationBridge(environmentProfile?: EnvironmentProfileConfig | null): Promise { try { const stack = normalizeVisualizationStack(environmentProfile?.type?.toLowerCase()) const resolvedProfile = withRuntimeProfileDefaults(environmentProfile, stack) if (normalizeRuntimeTarget(resolvedProfile?.target) === 'remote-jetson' && resolvedProfile) { return await callRemoteVisualizationAgent(resolvedProfile, 'stop', { stack }) } const statuses = await readDockerStatuses().catch(() => new Map()) const targetNames = new Set((['autoware', 'nav2', 'slam'] as VisualizationStack[]) .map(visualizationBridgeContainerName) ) getEnvironmentProfileContainerNames(resolvedProfile).forEach((name) => targetNames.add(name)) if (statuses.get(visualizationBridgeContainerName('slam'))?.running && statuses.get(slamSimulationRuntimeContainerName)?.running) { targetNames.add(slamSimulationRuntimeContainerName) } const running = Array.from(targetNames).filter((name) => statuses.get(name)?.running) if (running.length) await runCommand('docker', ['stop', ...running], undefined, undefined, 30000) const stale = Array.from(targetNames).filter((name) => { const status = statuses.get(name) return status?.exists && !status.running }) if (stale.length) { await runCommand('docker', ['rm', '-f', ...stale], undefined, undefined, 15000).catch(() => undefined) } return { ok: true, running: false, stack: null } } catch (error) { return { ok: false, running: false, stack: null, message: error instanceof Error ? error.message : String(error) } } } async function stopRunningWorkspaceContainers(): Promise<{ ok: boolean; message: string }> { try { const stdout = await runCommandOutput('docker', ['ps', '--format', '{{.Names}}'], undefined, undefined, 5000) const containerNames = stdout .split('\n') .map((line) => line.trim()) .filter((name) => name && isAllowedContainerName(name)) const uniqueContainerNames = Array.from(new Set(containerNames)) if (!uniqueContainerNames.length) return { ok: true, message: '중지할 컨테이너가 없습니다.' } await runCommand('docker', ['stop', ...uniqueContainerNames], undefined, undefined, 30000) return { ok: true, message: '중지했습니다.' } } catch (error) { return { ok: false, message: error instanceof Error ? error.message : String(error) } } } async function installModuleFromCache( moduleName: string, workspaceRoot?: string | null, folderName?: string | null, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null, ): Promise<{ ok: boolean; module: CachedModuleInfo }> { const resolvedWorkspaceRoot = expandUserPath(workspaceRoot) if (!resolvedWorkspaceRoot) throw new Error('워크스페이스 경로가 설정되어 있지 않습니다.') const targetFolderName = folderName?.trim() || moduleName const targetRoot = resolve(resolvedWorkspaceRoot, targetFolderName) await mkdir(resolvedWorkspaceRoot, { recursive: true }) if (!existsSync(targetRoot)) { const normalizedTemplateRepositoryUrl = templateRepositoryUrl?.trim() const normalizedStudentRepositoryUrl = studentRepositoryUrl?.trim() if (normalizedTemplateRepositoryUrl) { const studentRepositoryWithCredential = normalizedStudentRepositoryUrl ? withGitCredential(normalizedStudentRepositoryUrl) : null const studentRepositoryBranches = studentRepositoryWithCredential ? await runCommandOutput('git', ['ls-remote', '--heads', studentRepositoryWithCredential], undefined, undefined, 15000).catch(() => '') : '' if (studentRepositoryWithCredential && studentRepositoryBranches.trim()) { await runCommand('git', ['clone', studentRepositoryWithCredential, targetRoot]) } else { await runCommand('git', ['clone', withGitCredential(normalizedTemplateRepositoryUrl), targetRoot]) if (studentRepositoryWithCredential) { await runCommand('git', ['remote', 'set-url', 'origin', studentRepositoryWithCredential], targetRoot) await runCommand('git', ['push', '-u', 'origin', 'HEAD:main'], targetRoot) const baseCommit = await runCommandOutput('git', ['rev-parse', 'HEAD'], targetRoot, undefined, 8000).then((output) => output.trim()) await writeStudioGitBaseCommit(targetRoot, baseCommit) } } } else { const sourceRoot = folderName ? resolve(moduleCacheRoot, folderName) : await findCachedModuleRoot(moduleName) if (!sourceRoot) throw new Error('캐시 모듈을 찾을 수 없습니다.') if (!(await stat(sourceRoot)).isDirectory()) throw new Error('캐시 모듈을 찾을 수 없습니다.') await cp(sourceRoot, targetRoot, { recursive: true, force: false, errorOnExist: true }) } } await applyModulePrebuiltArtifacts(targetRoot, options?.prebuiltArtifacts) const { name, category } = parseModuleFolderName(targetFolderName) return { ok: true, module: { state: 'stopped', sortOrder: 0, category, name, folderName: targetFolderName, sourcePath: templateRepositoryUrl?.trim() ? templateRepositoryUrl.trim() : resolve(moduleCacheRoot, targetFolderName), workspacePath: targetRoot, templateRepositoryUrl: templateRepositoryUrl ?? null, sourceType: 'GIT', prebuiltArtifacts: options?.prebuiltArtifacts ?? null, parameters: [], cpu: null, memory: null, runtime: '-' } } } async function installRemoteWorkspaceModule( targetInput: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null, ): Promise<{ ok: boolean; module: CachedModuleInfo }> { const target = normalizeRemoteTarget(targetInput) const targetFolderName = folderName?.trim() || moduleName const normalizedTemplateRepositoryUrl = templateRepositoryUrl?.trim() const normalizedStudentRepositoryUrl = studentRepositoryUrl?.trim() if (!normalizedTemplateRepositoryUrl) throw new Error('원격 모듈 설치에는 템플릿 저장소가 필요합니다.') const remoteTemplateRepositoryUrl = remoteRepositoryUrl(normalizedTemplateRepositoryUrl) const remoteStudentRepositoryUrl = normalizedStudentRepositoryUrl ? remoteRepositoryUrl(normalizedStudentRepositoryUrl) : '' const templateRepositoryWithCredential = withGitCredential(remoteTemplateRepositoryUrl) const studentRepositoryWithCredential = normalizedStudentRepositoryUrl ? withGitCredential(remoteStudentRepositoryUrl) : '' const targetRoot = remotePathJoin(target.workspaceRoot, targetFolderName) const prebuiltArtifacts = (options?.prebuiltArtifacts ?? []) .filter((artifact) => artifact?.url?.trim()) .map((artifact) => ({ url: artifact.url?.trim() ?? '', fileName: safeArtifactFileName(artifact.url?.trim() ?? '', artifact.fileName), checksumSha256: artifact.checksumSha256?.trim() ?? '', extractPath: artifact.extractPath?.trim() ?? '.', architecture: artifact.architecture?.trim().toLowerCase() ?? '', required: Boolean(artifact.required), })) const prebuiltJson = JSON.stringify(prebuiltArtifacts) const script = [ 'set -e', `ROOT=${quoteShellSingle(target.workspaceRoot)}`, ...expandRemotePathVar('ROOT'), `FOLDER=${quoteShellSingle(targetFolderName)}`, `TARGET="$ROOT/$FOLDER"`, `TEMPLATE_REPO="$(printf %s ${quoteShellSingle(encodeBase64(templateRepositoryWithCredential))} | base64 -d)"`, `STUDENT_REPO="$(printf %s ${quoteShellSingle(encodeBase64(studentRepositoryWithCredential))} | base64 -d)"`, `PREBUILT_JSON="$(printf %s ${quoteShellSingle(encodeBase64(prebuiltJson))} | base64 -d)"`, 'mkdir -p "$ROOT"', 'if [ ! -d "$TARGET/.git" ]; then git clone "$TEMPLATE_REPO" "$TARGET"; fi', 'git -C "$TARGET" remote set-url origin "$TEMPLATE_REPO"', 'git -C "$TARGET" fetch origin --prune', 'CURRENT_STUDENT="$(git -C "$TARGET" remote get-url student 2>/dev/null || true)"', 'if [ "$CURRENT_STUDENT" != "$STUDENT_REPO" ]; then', ' git -C "$TARGET" checkout -B main origin/main', ' git -C "$TARGET" reset --hard origin/main', ' git -C "$TARGET" clean -fdx', ' git -C "$TARGET" remote remove student >/dev/null 2>&1 || true', ' git -C "$TARGET" remote add student "$STUDENT_REPO"', ' git -C "$TARGET" fetch student --prune || true', ' if git -C "$TARGET" show-ref --verify --quiet refs/remotes/student/main; then', ' git -C "$TARGET" checkout -B student-work student/main', ' else', ' git -C "$TARGET" checkout -B student-work origin/main', ' git -C "$TARGET" push student student-work:main', ' fi', 'fi', 'rm -rf "$TARGET/build" "$TARGET/install" "$TARGET/log"', 'ARTIFACT_CACHE="${XDG_CACHE_HOME:-$HOME/.cache}/autonomy-studio/artifacts"', 'mkdir -p "$ARTIFACT_CACHE"', 'python3 - "$PREBUILT_JSON" "$ARTIFACT_CACHE" "$TARGET" <<\'PY\'', 'import json, os, subprocess, sys', 'artifacts = json.loads(sys.argv[1] or "[]")', 'machine = os.uname().machine.lower()', 'architecture = "arm64" if machine in {"aarch64", "arm64"} else "amd64" if machine in {"x86_64", "amd64"} else machine', 'cache = sys.argv[2]', 'target = sys.argv[3]', 'def tar_args(path, mode):', ' lower = path.lower()', ' if lower.endswith(".tar.zst") or lower.endswith(".tzst"): return ["--zstd", "-tf" if mode == "list" else "-xf"]', ' if lower.endswith(".tar.gz") or lower.endswith(".tgz"): return ["-tzf" if mode == "list" else "-xzf"]', ' return ["-tf" if mode == "list" else "-xf"]', 'for item in artifacts:', ' target_architecture = (item.get("architecture") or "").strip().lower()', ' if target_architecture and target_architecture != architecture: continue', ' url = (item.get("url") or "").strip()', ' if not url: continue', ' name = item.get("fileName") or os.path.basename(url.split("?", 1)[0]) or "artifact.tar"', ' path = os.path.join(cache, name)', ' # Stable latest URLs must not reuse a same-named local artifact.', ' if os.path.exists(path): os.unlink(path)', ' subprocess.run(["curl", "-fL", url, "-o", path], check=True)', ' expected = (item.get("checksumSha256") or "").strip().lower()', ' if expected:', ' import hashlib', ' def checksum(file_path):', ' with open(file_path, "rb") as fp: return hashlib.sha256(fp.read()).hexdigest().lower()', ' actual = checksum(path)', ' if actual != expected:', ' # Stable artifact URLs may have a newer object than the local cache.', ' os.unlink(path)', ' subprocess.run(["curl", "-fL", url, "-o", path], check=True)', ' actual = checksum(path)', ' if actual != expected: raise SystemExit(f"artifact checksum mismatch: {name}")', ' entries = subprocess.check_output(["tar", *tar_args(path, "list"), path], text=True)', ' for entry in entries.splitlines():', ' entry = entry.strip()', ' if entry.startswith("/") or "../" in entry or entry == "..": raise SystemExit(f"unsafe artifact path: {entry}")', ' extract_path = os.path.abspath(os.path.join(target, item.get("extractPath") or "."))', ' if extract_path != target and not extract_path.startswith(target + os.sep): raise SystemExit("artifact extract path is outside module root")', ' os.makedirs(extract_path, exist_ok=True)', ' subprocess.run(["tar", *tar_args(path, "extract"), path, "-C", extract_path], check=True)', 'PY', 'printf "OK\\t%s\\n" "$TARGET"' ].join('\n') await runSsh(target, script, 180000) const { name, category } = parseModuleFolderName(targetFolderName) return { ok: true, module: { state: 'stopped', sortOrder: 0, category, name, folderName: targetFolderName, sourcePath: normalizedTemplateRepositoryUrl, workspacePath: targetRoot, templateRepositoryUrl: normalizedTemplateRepositoryUrl, sourceType: 'GIT', prebuiltArtifacts: options?.prebuiltArtifacts ?? null, parameters: [], cpu: null, memory: null, runtime: '-' } } } async function deleteWorkspaceModule(moduleName: string, workspaceRoot?: string | null, folderName?: string | null): Promise<{ ok: boolean }> { const moduleRoot = await findWorkspaceModuleRoot(moduleName, workspaceRoot, folderName) if (!moduleRoot) return { ok: true } await deleteLocalWorkspacePath(moduleRoot, workspaceRoot) return { ok: true } } function isPermissionError(error: unknown): boolean { const code = (error as NodeJS.ErrnoException | undefined)?.code return code === 'EACCES' || code === 'EPERM' } async function resolveWorkspaceCleanupImage(): Promise { const candidates = [nav2DockerImage, 'future-nav2-gazebo:humble', 'future-nav2:humble'] for (const image of candidates) { const available = await runCommandOutput('docker', ['image', 'inspect', image], undefined, undefined, 5000) .then(() => true) .catch(() => false) if (available) return image } return null } async function deleteLocalWorkspacePath(targetPath: string, workspaceRoot?: string | null): Promise { try { await rm(targetPath, { recursive: true, force: true }) return } catch (error) { if (!isPermissionError(error)) throw error const resolvedWorkspaceRoot = expandUserPath(workspaceRoot) const relativeTargetPath = resolvedWorkspaceRoot ? relative(resolvedWorkspaceRoot, targetPath) : '' if (!resolvedWorkspaceRoot || !relativeTargetPath || relativeTargetPath === '..' || relativeTargetPath.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`) || resolve(resolvedWorkspaceRoot, relativeTargetPath) !== resolve(targetPath)) { throw error } const image = await resolveWorkspaceCleanupImage() if (!image) throw error const containerTargetPath = `/workspace/${relativeTargetPath.split('\\').join('/')}` await runCommand('docker', [ 'run', '--rm', '--user', '0:0', '-v', `${resolvedWorkspaceRoot}:/workspace`, '--entrypoint', '/bin/sh', image, '-lc', `rm -rf -- ${quoteShellSingle(containerTargetPath)}` ], undefined, undefined, 30000) if (existsSync(targetPath)) throw error } } function resolveAutowareDockerRunScript(): string { return resolve(studioRuntimeRoot, 'docker', 'docker_run_autoware-universe:humble-20240301-cuda.sh') } function buildAutowareDockerRunScript(): string { return [ '#!/usr/bin/env bash', 'set -e', '', 'DOCKER_CONTAINER_NAME=$1', 'DOCKER_USER=root', 'SHARD_FOLDER_PATH=${SHARD_DIR}/$2', 'DOCKER_SHARD_FOLDER_PATH=/autoware', 'DOCKER_IMAGE_NAME=$5', 'ROS_PARAMS=$6', 'ROS_DOMAIN_ID=$7', 'GPU_ENABLED=${8:-false}', 'BUILD_BEFORE_RUN=${9:-false}', 'CYCLONE_CONFIG_PATH=${CYCLONEDDS_CONFIG_PATH:-./docker/cyclonedds_config.xml}', 'ROS_LOCALHOST_ONLY_VALUE=${ROS_LOCALHOST_ONLY:-1}', 'GPU_ARGS=()', 'GL_ARGS=(-e LIBGL_ALWAYS_SOFTWARE=1)', 'CONTAINER_COMMAND=\'cd /autoware && bash /autoware/run.sh "$@"\'', 'if [[ "$BUILD_BEFORE_RUN" == "true" || "$BUILD_BEFORE_RUN" == "1" ]]; then', ' CONTAINER_COMMAND=\'cd /autoware && rm -rf build install log && source /opt/ros/humble/setup.bash && colcon build --symlink-install && bash /autoware/run.sh "$@"\'', 'fi', 'if [[ "$GPU_ENABLED" == "true" || "$GPU_ENABLED" == "1" ]]; then', ' GPU_ARGS=(--gpus all)', ' GL_ARGS=(-e NVIDIA_VISIBLE_DEVICES=all -e NVIDIA_DRIVER_CAPABILITIES=graphics,compute,utility,display)', 'fi', '', 'docker run \\', ' -d -P --name "$DOCKER_CONTAINER_NAME" \\', ' "${GPU_ARGS[@]}" \\', ' --volume="$SHARD_FOLDER_PATH:$DOCKER_SHARD_FOLDER_PATH:rw" \\', ' -v ./config:$DOCKER_SHARD_FOLDER_PATH/config/ \\', ' -u "$DOCKER_USER" \\', ' -e DISPLAY -e TERM -e QT_X11_NO_MITSHM=1 \\', ' "${GL_ARGS[@]}" \\', ' -v /tmp/.X11-unix:/tmp/.X11-unix \\', ' -v /etc/localtime:/etc/localtime:ro \\', ' -e RCUTILS_COLORIZED_OUTPUT=1 \\', ' -e ROS_LOCALHOST_ONLY="$ROS_LOCALHOST_ONLY_VALUE" \\', ' -v "$CYCLONE_CONFIG_PATH:/cyclonedds_config.xml" \\', ' -e CYCLONEDDS_URI=file:///cyclonedds_config.xml \\', ' -v /dev/bus/usb:/dev/bus/usb \\', ' --device /dev/snd \\', ' --net=host \\', ' -e ROS_DOMAIN_ID="$ROS_DOMAIN_ID" \\', ' --entrypoint /bin/bash \\', ' "$DOCKER_IMAGE_NAME" -lc "$CONTAINER_COMMAND" _ "$ROS_PARAMS"', '' ].join('\n') } function parseRosDomainId(input?: string | null): number { const value = Number.parseInt(String(input ?? '0').trim(), 10) return Number.isInteger(value) && value >= 0 && value <= 232 ? value : 0 } function buildFastDdsPeerConfig(peerAddress: string, localAddress: string, rosDomainId?: string | null): string { const domainId = parseRosDomainId(rosDomainId) const discoveryBasePort = 7400 + (250 * domainId) // Fast DDS can bind the default discovery port itself (d0), while other // participants use d1 + 2 * participantId. Include both forms so a peer // remains discoverable regardless of its participant ID allocation. const peers = [ discoveryBasePort, ...Array.from({ length: 121 }, (_, participantId) => discoveryBasePort + 10 + (2 * participantId)) ] const localAddresses = Array.from(new Set(['127.0.0.1', localAddress])) const peerAddresses = Array.from(new Set(['127.0.0.1', peerAddress])) return [ '', '', ' ', ' ', ' autonomy_studio_udp', ' UDPv4', ' 1048576', ' 1048576', ' 1200', ' ', ...localAddresses.map((address) => `
${address}
`), '
', '
', '
', ' ', ' ', ' false', ' ', ' autonomy_studio_udp', ' ', ' ', ' ', ...localAddresses.flatMap((address) => [ ' ', ' ', `
${address}
`, ' 0', '
', '
', ]), '
', ' ', ...peerAddresses.flatMap((address) => peers.flatMap((port) => [ ' ', ' ', `
${address}
`, ` ${port}`, '
', '
', ])), '
', '
', '
', '
', '
', '' ].join('\n') } function buildCycloneDdsConfig(): string { return [ '', '', ' ', ' ', ' ', ' ', ' ', ' default', ' 65500B', ' ', ' ', ' auto', ' 120', ' ', ' ', ' ', ' 500kB', ' ', ' ', ' ', '', '' ].join('\n') } function resolveTailscaleIpv4(output: string): string { const address = output.split(/\s+/).find((value) => /^(?:\d{1,3}\.){3}\d{1,3}$/.test(value)) if (!address) throw new Error('Tailscale IPv4 주소를 확인하지 못했습니다.') return address } async function readLocalTailscaleIpv4(): Promise { const output = await runCommandOutput('tailscale', ['ip', '-4'], undefined, undefined, 10_000) return resolveTailscaleIpv4(output) } async function readRemoteTailscaleIpv4(target: RemoteWorkspaceTarget): Promise { const output = await runSshOutput(target, 'tailscale ip -4 2>/dev/null | head -n 1', 15_000) return resolveTailscaleIpv4(output) } function rosNetworkEnabled(network?: RosNetworkConfig | null): boolean { return network?.enabled === true } async function prepareLocalRosNetworkConfig( network?: RosNetworkConfig | null, rosDomainId?: string | null, ): Promise { if (!rosNetworkEnabled(network)) return null if (!network?.peer?.host) throw new Error('연동 대상 플랫폼의 SSH 주소를 확인하지 못했습니다.') const requestedPeerHost = network.peer.host.trim() const [peerAddress, localAddress] = await Promise.all([ /^(?:\d{1,3}\.){3}\d{1,3}$/.test(requestedPeerHost) ? Promise.resolve(requestedPeerHost) : readRemoteTailscaleIpv4(network.peer), readLocalTailscaleIpv4(), ]) const dockerRoot = resolveNav2RuntimeDockerRoot() await mkdir(dockerRoot, { recursive: true }) const configPath = join(dockerRoot, `fastdds-peer-${peerAddress.replace(/[^0-9]/g, '-')}-d${parseRosDomainId(rosDomainId)}.xml`) await writeFile(configPath, buildFastDdsPeerConfig(peerAddress, localAddress, rosDomainId), 'utf8') return configPath } async function prepareRemoteRosNetworkConfig( target: RemoteWorkspaceTarget, runtimeRoot: string, network?: RosNetworkConfig | null, rosDomainId?: string | null, ): Promise { if (!rosNetworkEnabled(network)) return null const [peerAddress, localAddress] = await Promise.all([ readLocalTailscaleIpv4(), readRemoteTailscaleIpv4(target), ]) const configPath = `${runtimeRoot}/docker/fastdds-peer-${peerAddress.replace(/[^0-9]/g, '-')}-d${parseRosDomainId(rosDomainId)}.xml` await runSsh(target, [ 'set -e', `CONFIG_PATH=${quoteShellSingle(configPath)}`, ...expandRemotePathVar('CONFIG_PATH'), 'mkdir -p "$(dirname \"$CONFIG_PATH\")"', `printf %s ${quoteShellSingle(encodeBase64(buildFastDdsPeerConfig(peerAddress, localAddress, rosDomainId)))} | base64 -d > "$CONFIG_PATH"`, ].join('\n'), 15_000) return configPath } function resolveNav2RuntimeDockerRoot(): string { return resolve(studioRuntimeRoot, 'nav2', 'docker') } function resolveNav2DockerRunScript(): string { return resolve(resolveNav2RuntimeDockerRoot(), 'docker_run_nav2.sh') } function resolveNav2DockerBuildScript(): string { return resolve(resolveNav2RuntimeDockerRoot(), 'docker_build_nav2.sh') } function buildNav2ModuleEnvScript(): string { return [ '#!/usr/bin/env bash', 'set -eo pipefail', '', 'MODULE_ROOT=${MODULE_ROOT:-/modules}', 'set +u', 'MODULES=(', ' "[lms-nav2-system]nav2-system_v1.0.0_ws"', ' "[lms-nav2-vehicle]nav2-vehicle_v1.0.0_ws"', ' "[lms-nav2-simulator]nav2-simulator_v1.0.0_ws"', ' "[lms-nav2-sensing]nav2-sensing_v1.0.0_ws"', ' "[lms-nav2-map]nav2-map_v1.0.0_ws"', ' "[lms-nav2-localization]nav2-localization_v1.0.0_ws"', ' "[lms-nav2-planning]nav2-planning_v1.0.0_ws"', ' "[lms-nav2-control]nav2-control_v1.0.0_ws"', ' "[lms-nav2-gazebo-simulator]nav2-simulator_v1.0.0_ws"', ' "[lms-nav2-gazebo-vehicle]nav2-vehicle_v1.0.0_ws"', ' "[lms-nav2-mkmini]nav2-mkmini_v1.0.0_ws"', ' "[lms-nav2-gazebo-map]nav2-map_v1.0.0_ws"', ' "[lms-nav2-gazebo-slam]nav2-slam_v1.0.0_ws"', ' "[lms-nav2-gazebo-localization]nav2-localization_v1.0.0_ws"', ' "[lms-nav2-gazebo-planning]nav2-planning_v1.0.0_ws"', ' "[lms-nav2-gazebo-control]nav2-control_v1.0.0_ws"', ' "[lms-nav2-gazebo-perception]nav2-perception_v1.0.0_ws"', ' "[lms-nav2-gazebo-perception3d]nav2-perception3d_v1.0.0_ws"', ' "[lms-nav2-gazebo-slam3d]nav2-slam3d_v1.0.0_ws"', ' "[lms-nav2-gazebo-gui]nav2-rviz2_v1.0.0_ws"', ')', '', 'for module_dir in "${MODULES[@]}"; do', ' if [ "$module_dir" = "${NAV2_SKIP_MODULE_DIR:-}" ]; then', ' continue', ' fi', ' setup="$MODULE_ROOT/$module_dir/install/setup.bash"', ' if [ -f "$setup" ]; then', ' source "$setup"', ' fi', 'done', '' ].join('\n') } function buildNav2DockerRunScript(): string { return [ '#!/usr/bin/env bash', 'set -euo pipefail', '', 'CONTAINER_NAME=$1', 'MODULE_ROOT=$2', 'MODULE_FOLDER=$3', 'IMAGE=$4', 'ROS_DOMAIN_ID_VALUE=${5:-0}', 'GPU_ENABLED=${6:-false}', 'shift 6 || true', 'FASTDDS_CONFIG_PATH=${FASTDDS_CONFIG_PATH:-}', 'ROS_LOCALHOST_ONLY_VALUE=${ROS_LOCALHOST_ONLY:-1}', 'LOCALIZATION_MODE=slam', 'WORLD_LAYOUT_PATH_VALUE=/modules/world-layout.json', 'filtered_args=()', 'for arg in "$@"; do', ' case "$arg" in', ' localization_mode:=amcl)', ' LOCALIZATION_MODE=amcl', ' ;;', ' world_layout_path:=*)', ' candidate_path=${arg#world_layout_path:=}', ' case "$candidate_path" in', ' /modules/maps/*.gazebo-layout.json|/modules/maps/*.gazebo-3d-layout.json)', ' WORLD_LAYOUT_PATH_VALUE=$candidate_path', ' ;;', ' *)', ' echo "Invalid saved map world layout path: $candidate_path" >&2', ' exit 64', ' ;;', ' esac', ' ;;', ' *)', ' filtered_args+=("$arg")', ' ;;', ' esac', 'done', 'set -- "${filtered_args[@]}"', '', 'if [[ "$MODULE_FOLDER" == *lms-nav2-custom* ]]; then', ' MODULE_CONFIG="$MODULE_ROOT/$MODULE_FOLDER/.autonomy-studio-module.json"', ' test -f "$MODULE_CONFIG" || { echo "Custom module configuration is missing: $MODULE_FOLDER" >&2; exit 64; }', ' read -r PACKAGE LAUNCH < <(python3 - "$MODULE_CONFIG" <<\'PY\'', 'import json, sys', 'config = json.load(open(sys.argv[1], encoding="utf-8"))', 'print(config["packageName"], config["launchFile"])', 'PY', ' )', 'else', 'case "$MODULE_FOLDER" in', ' *lms-nav2-mkmini*) PACKAGE=vehicle_pkg; LAUNCH=vehicle.launch.py ;;', ' *lms-nav2-gazebo-simulator*) PACKAGE=nav2_turtlebot_simulator; LAUNCH=simulator.launch.py ;;', ' *lms-nav2-gazebo-vehicle*) PACKAGE=vehicle_pkg; LAUNCH=vehicle.launch.py ;;', ' *lms-nav2-gazebo-map*) PACKAGE=map_pkg; LAUNCH=gazebo_map.launch.py ;;', ' *lms-nav2-gazebo-slam3d*) PACKAGE=slam3d_pkg; LAUNCH=slam.launch.py ;;', ' *lms-nav2-gazebo-localization*) PACKAGE=localization_pkg; LAUNCH=localization.launch.py ;;', ' *lms-nav2-gazebo-planning*) PACKAGE=planning_pkg; LAUNCH=planning.launch.py ;;', ' *lms-nav2-gazebo-control*) PACKAGE=control_pkg; LAUNCH=control.launch.py ;;', ' *lms-nav2-gazebo-perception3d*) PACKAGE=perception3d_pkg; LAUNCH=perception.launch.py ;;', ' *lms-nav2-gazebo-gui*) PACKAGE=gui_pkg; LAUNCH=gui.launch.py ;;', ' *lms-nav2-gazebo-slam*) PACKAGE=slam_pkg; LAUNCH=slam.launch.py ;;', ' *lms-nav2-gazebo-perception*) PACKAGE=perception_pkg; LAUNCH=perception.launch.py ;;', ' *nav2-simulator*) PACKAGE=nav2_turtlebot_simulator; LAUNCH=simulator.launch.py ;;', ' *nav2-vehicle*) PACKAGE=nav2_turtlebot_vehicle; LAUNCH=vehicle.launch.py ;;', ' *nav2-sensing*) PACKAGE=nav2_turtlebot_sensing; LAUNCH=sensing.launch.py ;;', ' *nav2-map*) PACKAGE=nav2_turtlebot_map; LAUNCH=map_server.launch.py ;;', ' *nav2-localization*) PACKAGE=nav2_turtlebot_localization; LAUNCH="$LOCALIZATION_MODE.launch.py" ;;', ' *nav2-planning*) PACKAGE=nav2_turtlebot_planning; LAUNCH=planning.launch.py ;;', ' *nav2-control*) PACKAGE=nav2_turtlebot_control; LAUNCH=control.launch.py ;;', ' *nav2-system*) PACKAGE=nav2_turtlebot_system; LAUNCH=system.launch.py ;;', ' *) echo "Unknown Nav2 module: $MODULE_FOLDER" >&2; exit 64 ;;', 'esac', 'fi', '', 'if [[ "$MODULE_FOLDER" == *lms-nav2-gazebo-slam3d* || "$MODULE_FOLDER" == *lms-nav2-gazebo-localization* ]]; then', ' mkdir -p "$MODULE_ROOT/maps"', ' has_database_path=false', ' for arg in "$@"; do', ' if [[ "$arg" == database_path:=* ]]; then has_database_path=true; break; fi', ' done', ' if [[ "$MODULE_FOLDER" == *lms-nav2-gazebo-slam3d* && "$has_database_path" = false ]] || [[ "$MODULE_FOLDER" == *lms-nav2-gazebo-localization* && "$has_database_path" = false ]]; then', ' set -- "database_path:=/modules/maps/rtabmap-active.db" "$@"', ' fi', 'fi', '', 'test -f "$MODULE_ROOT/$MODULE_FOLDER/install/setup.bash" || { echo "Module is not built: $MODULE_FOLDER" >&2; exit 1; }', 'docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true', '', 'args=(', ' --rm -d --network host --ipc=host', ' --name "$CONTAINER_NAME"', ' -e "ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VALUE"', ' -e "ROS_LOCALHOST_ONLY=$ROS_LOCALHOST_ONLY_VALUE"', ' -e "TURTLEBOT3_MODEL=${TURTLEBOT3_MODEL:-burger}"', ' -e "MODULE_ROOT=/modules"', ' -e "MODULE_FOLDER=$MODULE_FOLDER"', ' -e "WORLD_LAYOUT_PATH=$WORLD_LAYOUT_PATH_VALUE"', ' -e "GAZEBO_MODEL_PATH=/usr/share/gazebo-11/models:/modules/[lms-nav2-simulator]nav2-simulator_v1.0.0_ws/install/turtlebot3_gazebo/share/turtlebot3_gazebo/models:/modules/[lms-nav2-gazebo-simulator]nav2-simulator_v1.0.0_ws/install/turtlebot3_gazebo/share/turtlebot3_gazebo/models"', ' -v "$MODULE_ROOT:/modules"', ' -v "$(dirname "$0"):/runtime/nav2/docker:ro"', ' -v /etc/localtime:/etc/localtime:ro', ')', 'if [[ "$GPU_ENABLED" == "true" || "$GPU_ENABLED" == "1" ]]; then', ' args+=(--gpus all -e NVIDIA_VISIBLE_DEVICES=all -e NVIDIA_DRIVER_CAPABILITIES=compute,utility,graphics)', 'fi', 'if [ -n "$FASTDDS_CONFIG_PATH" ] && [ -f "$FASTDDS_CONFIG_PATH" ]; then', ' args+=(-v "$FASTDDS_CONFIG_PATH:/fastdds_profiles.xml:ro" -e "FASTDDS_DEFAULT_PROFILES_FILE=/fastdds_profiles.xml" -e "FASTRTPS_DEFAULT_PROFILES_FILE=/fastdds_profiles.xml")', 'fi', 'if [ -d /dev/dri ]; then args+=(--device /dev/dri); fi', 'if [ -n "${DISPLAY:-}" ] && [ -d /tmp/.X11-unix ]; then', ' args+=(-e "DISPLAY=$DISPLAY" -e QT_X11_NO_MITSHM=1 -v /tmp/.X11-unix:/tmp/.X11-unix:rw)', 'fi', '', "exec docker run \"${args[@]}\" \"$IMAGE\" bash -lc '", ' set -e', ' source /opt/ros/humble/setup.bash', ' source /runtime/nav2/docker/module-env.sh', ' source "$MODULE_ROOT/$MODULE_FOLDER/install/setup.bash"', ' exec ros2 launch "$@"', "' bash \"$PACKAGE\" \"$LAUNCH\" \"$@\"", '' ].join('\n') } function buildNav2DockerBuildScript(): string { return [ '#!/usr/bin/env bash', 'set -euo pipefail', '', 'MODULE_ROOT=$1', 'MODULE_FOLDER=$2', 'IMAGE=$3', 'ROS_DOMAIN_ID_VALUE=${4:-0}', '', 'if [[ "$MODULE_FOLDER" == *lms-nav2-custom* ]]; then', ' MODULE_CONFIG="$MODULE_ROOT/$MODULE_FOLDER/.autonomy-studio-module.json"', ' test -f "$MODULE_CONFIG" || { echo "Custom module configuration is missing: $MODULE_FOLDER" >&2; exit 64; }', ' TARGET=$(python3 - "$MODULE_CONFIG" <<\'PY\'', 'import json, sys', 'print(json.load(open(sys.argv[1], encoding="utf-8"))["packageName"])', 'PY', ' )', 'else', 'case "$MODULE_FOLDER" in', ' *lms-nav2-mkmini*) TARGET=vehicle_pkg ;;', ' *lms-nav2-gazebo-simulator*) TARGET=nav2_turtlebot_simulator ;;', ' *lms-nav2-gazebo-vehicle*) TARGET=vehicle_pkg ;;', ' *lms-nav2-gazebo-map*) TARGET=map_pkg ;;', ' *lms-nav2-gazebo-slam3d*) TARGET=slam3d_pkg ;;', ' *lms-nav2-gazebo-localization*) TARGET=localization_pkg ;;', ' *lms-nav2-gazebo-planning*) TARGET=planning_pkg ;;', ' *lms-nav2-gazebo-control*) TARGET=control_pkg ;;', ' *lms-nav2-gazebo-perception3d*) TARGET=perception3d_pkg ;;', ' *lms-nav2-gazebo-gui*) TARGET=gui_pkg ;;', ' *lms-nav2-gazebo-slam*) TARGET=slam_pkg ;;', ' *lms-nav2-gazebo-perception*) TARGET=perception_pkg ;;', ' *nav2-simulator*) TARGET=nav2_turtlebot_simulator ;;', ' *nav2-vehicle*) TARGET=nav2_turtlebot_vehicle ;;', ' *nav2-sensing*) TARGET=nav2_turtlebot_sensing ;;', ' *nav2-map*) TARGET=nav2_turtlebot_map ;;', ' *nav2-localization*) TARGET=nav2_turtlebot_localization ;;', ' *nav2-planning*) TARGET=nav2_turtlebot_planning ;;', ' *nav2-control*) TARGET=nav2_turtlebot_control ;;', ' *nav2-system*) TARGET=nav2_turtlebot_system ;;', ' *) echo "Unknown Nav2 module: $MODULE_FOLDER" >&2; exit 64 ;;', 'esac', 'fi', '', 'MODULE_PATH="$MODULE_ROOT/$MODULE_FOLDER"', 'if [[ "$MODULE_FOLDER" == *lms-nav2-custom* ]]; then', ' if [ -f "$MODULE_PATH/src/$TARGET/package.xml" ]; then', ' SOURCE_BASE_PATH=src', ' elif [ -f "$MODULE_PATH/package.xml" ]; then', ' SOURCE_BASE_PATH=.', ' else', ' echo "Custom module source is missing: $MODULE_FOLDER" >&2; exit 1', ' fi', 'else', ' test -d "$MODULE_PATH/src" || { echo "Module source is missing: $MODULE_FOLDER" >&2; exit 1; }', ' SOURCE_BASE_PATH=src', 'fi', 'BUILD_CONTAINER="nav2-build-${TARGET}"', 'cleanup() {', ' docker run --rm -v "$MODULE_ROOT:/modules" "$IMAGE" bash -lc "chown -R $(id -u):$(id -g) \'/modules/$MODULE_FOLDER/build\' \'/modules/$MODULE_FOLDER/install\' \'/modules/$MODULE_FOLDER/log\' 2>/dev/null || true" >/dev/null 2>&1 || true', '}', 'trap cleanup EXIT', 'docker rm -f "$BUILD_CONTAINER" >/dev/null 2>&1 || true', "docker run --rm --network host --name \"$BUILD_CONTAINER\" \\", " -v \"$MODULE_ROOT:/modules\" \\", " -v \"$(dirname \"$0\"):/runtime/nav2/docker:ro\" \\", " -w \"/modules/$MODULE_FOLDER\" \\", " -e \"ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VALUE\" \\", " \"$IMAGE\" bash -lc '", ' set -e', ' source /opt/ros/humble/setup.bash', ' mapfile -t paths < <(colcon list --base-paths "$3" --paths-only)', ' ROSDEP_SKIP_ARGS=()', ' if [[ "$1" == *lms-nav2-custom* ]]; then', ' ROSDEP_SKIP_ARGS+=(--skip-keys ament_python)', ' fi', ' if ! apt-cache show ros-humble-gazebo-ros >/dev/null 2>&1; then', ' ROSDEP_SKIP_ARGS+=(--skip-keys gazebo_ros)', ' fi', " rosdep install --from-paths \"${paths[@]}\" --ignore-src \"${ROSDEP_SKIP_ARGS[@]}\" \\", " --dependency-types buildtool --dependency-types build \\", ' --dependency-types build_export --dependency-types exec -r -y', ' BUILD_TARGETS=("$2")', ' if [[ "$1" == *lms-nav2-gazebo-slam* && "$1" != *lms-nav2-gazebo-slam3d* ]]; then', ' BUILD_TARGETS=(slam_pkg slam2d_pkg slam3d_pkg)', ' fi', " colcon --log-base log build --base-paths \"$3\" --build-base build --install-base install \\", ' --executor sequential --cmake-args -DBUILD_TESTING=OFF --packages-up-to "${BUILD_TARGETS[@]}"', "' bash \"$MODULE_FOLDER\" \"$TARGET\" \"$SOURCE_BASE_PATH\"", '' ].join('\n') } async function ensureNav2RuntimeAssets(): Promise { const dockerRoot = resolveNav2RuntimeDockerRoot() await mkdir(dockerRoot, { recursive: true }) await writeFile(resolveNav2DockerRunScript(), buildNav2DockerRunScript(), 'utf8') await writeFile(resolveNav2DockerBuildScript(), buildNav2DockerBuildScript(), 'utf8') await writeFile(resolve(dockerRoot, 'module-env.sh'), buildNav2ModuleEnvScript(), 'utf8') await writeFile(resolve(dockerRoot, 'cyclonedds_config.xml'), buildCycloneDdsConfig(), 'utf8') } async function ensureStudioRuntimeAssets(): Promise { const dockerRoot = resolve(studioRuntimeRoot, 'docker') const configRoot = resolve(studioRuntimeRoot, 'config') const scriptPath = resolveAutowareDockerRunScript() const legacyScriptPath = resolve(studioRuntimeRoot, 'docker', 'docker_run_autoware-universe_humble-20240301-cuda.sh') const cycloneConfigPath = resolve(dockerRoot, 'cyclonedds_config.xml') await mkdir(dockerRoot, { recursive: true }) await mkdir(configRoot, { recursive: true }) await writeFile(scriptPath, buildAutowareDockerRunScript(), 'utf8') await writeFile(legacyScriptPath, buildAutowareDockerRunScript(), 'utf8') await writeFile(cycloneConfigPath, buildCycloneDdsConfig(), 'utf8') } async function ensureRemoteStudioRuntimeAssets(targetInput?: RemoteWorkspaceTarget | null): Promise { const target = normalizeRemoteTarget(targetInput) const runtimeRoot = '~/autonomy-studio/runtime' const scriptBase64 = encodeBase64(buildAutowareDockerRunScript()) const cycloneBase64 = encodeBase64(buildCycloneDdsConfig()) await runSsh(target, [ 'set -e', `RUNTIME_ROOT=${quoteShellSingle(runtimeRoot)}`, ...expandRemotePathVar('RUNTIME_ROOT'), 'DOCKER_ROOT="$RUNTIME_ROOT/docker"', 'CONFIG_ROOT="$RUNTIME_ROOT/config"', 'SCRIPT_PATH="$DOCKER_ROOT/docker_run_autoware-universe:humble-20240301-cuda.sh"', 'LEGACY_SCRIPT_PATH="$DOCKER_ROOT/docker_run_autoware-universe_humble-20240301-cuda.sh"', 'CYCLONE_CONFIG_PATH="$DOCKER_ROOT/cyclonedds_config.xml"', 'mkdir -p "$DOCKER_ROOT" "$CONFIG_ROOT"', `printf %s ${quoteShellSingle(scriptBase64)} | base64 -d > "$SCRIPT_PATH"`, 'cp "$SCRIPT_PATH" "$LEGACY_SCRIPT_PATH"', 'chmod +x "$SCRIPT_PATH" "$LEGACY_SCRIPT_PATH"', `printf %s ${quoteShellSingle(cycloneBase64)} | base64 -d > "$CYCLONE_CONFIG_PATH"` ].join('\n'), 15000) return runtimeRoot } async function ensureRemoteNav2RuntimeAssets(targetInput?: RemoteWorkspaceTarget | null): Promise { const target = normalizeRemoteTarget(targetInput) const runtimeRoot = '~/autonomy-studio/runtime/nav2' const assets = { 'docker_run_nav2.sh': buildNav2DockerRunScript(), 'docker_build_nav2.sh': buildNav2DockerBuildScript(), 'module-env.sh': buildNav2ModuleEnvScript(), 'cyclonedds_config.xml': buildCycloneDdsConfig(), } const assetRevision = createHash('sha256') .update(Object.entries(assets).map(([name, content]) => `${name}\n${content}`).join('\n')) .digest('hex') const revisionFile = '.autonomy-studio-nav2-runtime-revision' const revisionCommand = [ `RUNTIME_ROOT=${quoteShellSingle(runtimeRoot)}`, ...expandRemotePathVar('RUNTIME_ROOT'), 'DOCKER_ROOT="$RUNTIME_ROOT/docker"', `if [ -f "$DOCKER_ROOT/${revisionFile}" ]; then cat "$DOCKER_ROOT/${revisionFile}"; fi` ].join('\n') const installedRevision = (await runSshOutput(target, revisionCommand, 20_000)).trim() if (installedRevision === assetRevision) return runtimeRoot const assetWrites = Object.entries(assets).map(([name, content]) => ( `printf %s ${quoteShellSingle(encodeBase64(content))} | base64 -d > "$DOCKER_ROOT/${name}"` )) try { await runSsh(target, [ 'set -e', `RUNTIME_ROOT=${quoteShellSingle(runtimeRoot)}`, ...expandRemotePathVar('RUNTIME_ROOT'), 'DOCKER_ROOT="$RUNTIME_ROOT/docker"', 'mkdir -p "$DOCKER_ROOT"', ...assetWrites, `printf %s ${quoteShellSingle(assetRevision)} > "$DOCKER_ROOT/${revisionFile}"`, 'chmod +x "$DOCKER_ROOT/docker_run_nav2.sh" "$DOCKER_ROOT/docker_build_nav2.sh" "$DOCKER_ROOT/module-env.sh"' ].join('\n'), 60_000) } catch (error) { if (error instanceof Error && error.message.includes('timed out')) { throw new Error('원격 NAV2 실행 환경 준비 시간이 초과되었습니다. 플랫폼 연결 상태를 확인한 뒤 다시 시도하세요.') } throw error } return runtimeRoot } async function readModuleRosParams(moduleRoot: string): Promise { try { const runScript = await readFile(resolve(moduleRoot, 'run.sh'), 'utf8') const quotedMatch = runScript.match(/^ROS_PARAM=(['"])(.*?)\1/m) if (quotedMatch) return quotedMatch[2].trim() const unquotedMatch = runScript.match(/^ROS_PARAM=([^\r\n#]*)/m) return unquotedMatch?.[1]?.trim() ?? '' } catch { return '' } } function splitRosParams(rosParams: string): string[] { return rosParams.trim() ? rosParams.trim().split(/\s+/) : [] } function quoteShellSingle(value: string): string { return `'${value.replace(/'/g, `'\\''`)}'` } async function updateWorkspaceModuleParameters( moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams = '', ): Promise<{ ok: boolean; parameters: string[] }> { const moduleRoot = await findWorkspaceModuleRoot(moduleName, workspaceRoot, folderName) if (!moduleRoot) throw new Error('설치된 모듈을 찾을 수 없습니다.') const runScriptPath = resolve(moduleRoot, 'run.sh') const currentScript = await readFile(runScriptPath, 'utf8') const nextParamLine = `ROS_PARAM=${quoteShellSingle(rosParams.trim())}` const nextScript = /^ROS_PARAM=.*$/m.test(currentScript) ? currentScript.replace(/^ROS_PARAM=.*$/m, nextParamLine) : /^RUN_CMD=.*$/m.test(currentScript) ? currentScript.replace(/^RUN_CMD=.*$/m, (line) => `${line}\n${nextParamLine}`) : `${currentScript.trimEnd()}\n${nextParamLine}\n` await writeFile(runScriptPath, nextScript, 'utf8') return { ok: true, parameters: splitRosParams(rosParams) } } async function findRunningNav2SimulatorContainer(): Promise { const containers = (await runCommandOutput( 'docker', ['ps', '--format', '{{.Names}}'], undefined, undefined, 10_000, )).trim().split(/\r?\n/) return containers.find((containerName) => ( isAllowedRuntimeContainerName(containerName) && /^lms-nav2(?:-gazebo)?-simulator-/i.test(containerName) )) ?? null } async function findNav2SimulatorRobotModel(containerName: string): Promise { // Query Gazebo directly first. The ROS GetModelList service can be slow or // unavailable while DDS participants are still discovering each other, even // though the simulator itself is already able to accept a pose update. const knownModels = ['mkmini_gazebo', 'turtlebot3_burger_3d_lidar', 'turtlebot3_burger'] for (const modelName of knownModels) { const pose = await runCommandOutput( 'docker', ['exec', containerName, 'gz', 'model', '-m', modelName, '-p'], undefined, undefined, 5_000, ).catch(() => '') const values = pose.trim().split(/\s+/).map(Number) if (values.length === 6 && values.every(Number.isFinite)) return modelName } const output = await runCommandOutput( 'docker', [ 'exec', containerName, 'bash', '-lc', 'source /opt/ros/humble/setup.bash && ros2 service call /get_model_list gazebo_msgs/srv/GetModelList "{}"', ], undefined, undefined, 15_000, ) const modelList = output.match(/model_names=\[([^\]]*)\]/)?.[1] ?? '' const modelNames = Array.from(modelList.matchAll(/'([^']+)'/g), (match) => match[1]) const robotModel = modelNames.find((modelName) => ( /turtlebot|burger|mkmini|mk-mini|robot/i.test(modelName) && !/world/i.test(modelName) )) if (!robotModel) throw new Error('Gazebo 로봇 모델을 찾을 수 없습니다.') return robotModel } async function setNav2SimulatorPose( pose: { x?: number; y?: number; yaw?: number } | null | undefined, ): Promise<{ ok: boolean; message: string }> { const x = Number(pose?.x) const y = Number(pose?.y) const yaw = Number(pose?.yaw ?? 0) if (![x, y, yaw].every(Number.isFinite)) throw new Error('로봇 위치 값이 올바르지 않습니다.') const containerName = await findRunningNav2SimulatorContainer() if (!containerName) throw new Error('실행 중인 NAV2 Simulator를 찾을 수 없습니다.') const robotModel = await findNav2SimulatorRobotModel(containerName) // MK-mini's model origin is higher than TurtleBot's. Teleporting it at the // TurtleBot ground height puts its chassis and wheels into the floor, which // makes Gazebo eject or tip the vehicle while resolving the collision. const spawnHeight = /^mkmini/i.test(robotModel) ? '0.16' : '0.01' await runCommand( 'docker', [ 'exec', containerName, 'gz', 'model', '-m', robotModel, '-x', String(x), '-y', String(y), '-z', spawnHeight, '-R', '0', '-P', '0', '-Y', String(yaw), ], undefined, undefined, 10_000, ) return { ok: true, message: '로봇 초기 위치를 적용했습니다.' } } type Nav2RuntimeObstacle = { id?: string x?: number y?: number yaw?: number length?: number depth?: number height?: number } async function addNav2RuntimeObstacle(obstacle: Nav2RuntimeObstacle | null | undefined): Promise<{ ok: boolean; message: string }> { const id = obstacle?.id?.trim() ?? '' const x = Number(obstacle?.x) const y = Number(obstacle?.y) const yaw = Number(obstacle?.yaw ?? 0) const length = Number(obstacle?.length) const depth = Number(obstacle?.depth) const height = Number(obstacle?.height) if (!/^runtime-wall-[a-z0-9-]{8,80}$/i.test(id) || ![x, y, yaw, length, depth, height].every(Number.isFinite)) { throw new Error('장애물 값이 올바르지 않습니다.') } if (length <= 0 || length > 3 || depth <= 0 || depth > 1 || height <= 0 || height > 2) { throw new Error('장애물 크기 범위를 벗어났습니다.') } const containerName = await findRunningNav2SimulatorContainer() if (!containerName) throw new Error('실행 중인 NAV2 Simulator를 찾을 수 없습니다.') const sdf = `true${length} ${depth} ${height}${length} ${depth} ${height}0.85 0.38 0.04 10.85 0.38 0.04 1` const sdfPath = `/tmp/${id}.sdf` const command = `printf '%s' '${Buffer.from(sdf).toString('base64')}' | base64 -d > '${sdfPath}' && gz model -m '${id}' -f '${sdfPath}' -x '${x}' -y '${y}' -z '${height / 2}' -R 0 -P 0 -Y '${yaw}'` await runCommand('docker', ['exec', containerName, 'sh', '-lc', command], undefined, undefined, 10_000) nav2RuntimeObstacleNames.add(id) return { ok: true, message: '장애물을 추가했습니다.' } } async function clearNav2RuntimeObstacles(): Promise<{ ok: boolean; message: string }> { const containerName = await findRunningNav2SimulatorContainer() if (!containerName) throw new Error('실행 중인 NAV2 Simulator를 찾을 수 없습니다.') const names = Array.from(nav2RuntimeObstacleNames) await Promise.all(names.map((name) => runCommand( 'docker', ['exec', containerName, 'gz', 'model', '-m', name, '-d'], undefined, undefined, 10_000, ))) nav2RuntimeObstacleNames.clear() return { ok: true, message: '장애물을 제거했습니다.' } } async function startWorkspaceModule( moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParamsOverride?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null, ): Promise<{ ok: boolean; message: string }> { const resolvedWorkspaceRoot = expandUserPath(workspaceRoot) if (!resolvedWorkspaceRoot) throw new Error('워크스페이스 경로가 설정되어 있지 않습니다.') const targetFolderName = folderName?.trim() if (!targetFolderName) throw new Error('모듈 폴더명이 없습니다.') const moduleRoot = await findWorkspaceModuleRoot(moduleName, resolvedWorkspaceRoot, targetFolderName) if (!moduleRoot) throw new Error('설치된 모듈을 찾을 수 없습니다.') const dockerInfo = parseModuleDockerInfo(targetFolderName) if (!dockerInfo || !isAllowedContainerName(dockerInfo.containerKey)) throw new Error('모듈 폴더명 형식이 올바르지 않습니다.') if (isNav2ModuleFolder(targetFolderName)) { await ensureNav2RuntimeAssets() const fastDdsConfigPath = await prepareLocalRosNetworkConfig(rosNetwork, rosDomainId) const rosParams = typeof rosParamsOverride === 'string' ? rosParamsOverride.trim() : await readModuleRosParams(moduleRoot) const scriptPath = resolveNav2DockerRunScript() const runtimeImage = await resolveCustomModuleRuntimeImage( moduleRoot, resolveNav2RuntimeImage(runtimeImages, targetFolderName, rosParams), ) await prepareRuntimeImages(requiredRuntimeImages(runtimeImages, runtimeImage)) await runCommand( 'bash', [ scriptPath, dockerInfo.containerKey, resolvedWorkspaceRoot, targetFolderName, runtimeImage, String(rosDomainId ?? process.env.ROS_DOMAIN_ID ?? '0').trim() || '0', gpuEnabled ? 'true' : 'false', ...splitRosParams(rosParams) ], undefined, { ROS_LOCALHOST_ONLY: rosNetworkEnabled(rosNetwork) ? '0' : '1', ...(fastDdsConfigPath ? { FASTDDS_CONFIG_PATH: fastDdsConfigPath } : {}), }, 30000 ) return { ok: true, message: '실행 요청을 보냈습니다.' } } await prepareRuntimeImages(runtimeImages) await ensureStudioRuntimeAssets() const scriptPath = resolveAutowareDockerRunScript() if (!existsSync(scriptPath)) throw new Error(`${scriptPath} 파일을 찾을 수 없습니다.`) const statuses = await readDockerStatuses().catch(() => new Map()) const current = statuses.get(dockerInfo.containerKey) if (current?.running) return { ok: true, message: '이미 실행 중입니다.' } if (current?.exists) { await runCommand('docker', ['rm', '-f', dockerInfo.containerKey], undefined, undefined, 10000).catch(() => undefined) } const rosParams = typeof rosParamsOverride === 'string' ? rosParamsOverride.trim() : await readModuleRosParams(moduleRoot) await runCommand( 'bash', [ scriptPath, dockerInfo.containerKey, targetFolderName, 'localhost', 'localhost', autowareDockerImage, rosParams, String(rosDomainId ?? process.env.ROS_DOMAIN_ID ?? '0').trim() || '0', gpuEnabled ? 'true' : 'false' ], studioRuntimeRoot, { SHARD_DIR: resolvedWorkspaceRoot, }, 30000 ) return { ok: true, message: '실행 요청을 보냈습니다.' } } async function buildWorkspaceModule( moduleName: string, workspaceRoot?: string | null, folderName?: string | null, _gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null, rosParamsOverride?: string | null, ): Promise<{ ok: boolean; message: string }> { const resolvedWorkspaceRoot = expandUserPath(workspaceRoot) if (!resolvedWorkspaceRoot) throw new Error('워크스페이스 경로가 설정되어 있지 않습니다.') const targetFolderName = folderName?.trim() if (!targetFolderName) throw new Error('모듈 폴더명이 없습니다.') const moduleRoot = await findWorkspaceModuleRoot(moduleName, resolvedWorkspaceRoot, targetFolderName) if (!moduleRoot) throw new Error('설치된 모듈을 찾을 수 없습니다.') const dockerInfo = parseModuleDockerInfo(targetFolderName) if (!dockerInfo || !isAllowedContainerName(dockerInfo.containerKey)) throw new Error('모듈 폴더명 형식이 올바르지 않습니다.') if (isNav2ModuleFolder(targetFolderName)) { await ensureNav2RuntimeAssets() const rosParams = typeof rosParamsOverride === 'string' ? rosParamsOverride.trim() : await readModuleRosParams(moduleRoot) const runtimeImage = await resolveCustomModuleRuntimeImage( moduleRoot, resolveNav2RuntimeImage(runtimeImages, targetFolderName, rosParams), ) await prepareRuntimeImages(requiredRuntimeImages(runtimeImages, runtimeImage)) await runCommand( 'bash', [ resolveNav2DockerBuildScript(), resolvedWorkspaceRoot, targetFolderName, runtimeImage, String(rosDomainId ?? process.env.ROS_DOMAIN_ID ?? '0').trim() || '0' ], undefined, undefined, 900000 ) return { ok: true, message: '빌드가 완료되었습니다.' } } await prepareRuntimeImages(runtimeImages) await runCommand( 'docker', [ 'run', '--rm', '--name', `${dockerInfo.containerKey}-build`, '--volume', `${moduleRoot}:/autoware:rw`, '-u', 'root', '--net=host', '-e', `ROS_DOMAIN_ID=${String(rosDomainId ?? process.env.ROS_DOMAIN_ID ?? '0').trim() || '0'}`, '-v', '/etc/localtime:/etc/localtime:ro', '--entrypoint', '/bin/bash', autowareDockerImage, '-lc', 'cd /autoware && source /opt/ros/humble/setup.bash && colcon build --symlink-install' ], undefined, undefined, 900000 ) return { ok: true, message: '빌드가 완료되었습니다.' } } async function prepareRemoteRuntimeImages( target: RemoteWorkspaceTarget, runtimeImages?: RuntimeImageArtifact[] | null, ): Promise { const artifacts = (runtimeImages ?? []) .filter((image) => image?.image?.trim()) .map((image) => ({ image: image.image?.trim() ?? '', url: image.url?.trim() ?? '', fileName: image.url?.trim() ? safeArtifactFileName(image.url.trim(), image.fileName) : '', checksumSha256: image.checksumSha256?.trim() ?? '', required: Boolean(image.required), })) const artifactsJson = JSON.stringify(artifacts) await runSsh( target, [ 'set -e', `RUNTIME_IMAGE_JSON="$(printf %s ${quoteShellSingle(encodeBase64(artifactsJson))} | base64 -d)"`, 'ARTIFACT_CACHE="${XDG_CACHE_HOME:-$HOME/.cache}/autonomy-studio/artifacts"', 'mkdir -p "$ARTIFACT_CACHE"', 'LOCK_FILE="$ARTIFACT_CACHE/runtime-images.lock"', 'flock "$LOCK_FILE" python3 - "$RUNTIME_IMAGE_JSON" "$ARTIFACT_CACHE" <<\'PY\'', 'import json, os, subprocess, sys', 'images = json.loads(sys.argv[1] or "[]")', 'cache = sys.argv[2]', 'for item in images:', ' image = (item.get("image") or "").strip()', ' if not image: continue', ' exists = subprocess.run(["docker", "image", "inspect", image], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode == 0', ' if exists: continue', ' url = (item.get("url") or "").strip()', ' if not url:', ' if item.get("required"): raise SystemExit(f"runtime image artifact url is missing: {image}")', ' continue', ' name = item.get("fileName") or os.path.basename(url.split("?", 1)[0]) or "runtime-image.tar"', ' path = os.path.join(cache, name)', ' if os.path.exists(path): os.unlink(path)', ' subprocess.run(["curl", "-fL", url, "-o", path], check=True)', ' expected = (item.get("checksumSha256") or "").strip().lower()', ' if expected:', ' import hashlib', ' with open(path, "rb") as fp: actual = hashlib.sha256(fp.read()).hexdigest().lower()', ' if actual != expected: raise SystemExit(f"runtime image checksum mismatch: {name}")', ' if path.lower().endswith(".tar.zst") or path.lower().endswith(".tzst"):', ' import shlex', ' subprocess.run(f"zstd -dc {shlex.quote(path)} | docker load", shell=True, check=True)', ' else:', ' subprocess.run(["docker", "load", "-i", path], check=True)', 'PY' ].join('\n'), 900000, ) } async function startRemoteWorkspaceModule( targetInput: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, rosParamsOverride?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null, ): Promise<{ ok: boolean; message: string }> { const target = normalizeRemoteTarget(targetInput) const targetFolderName = folderName?.trim() || moduleName if (!targetFolderName) throw new Error('모듈 폴더명이 없습니다.') const dockerInfo = parseModuleDockerInfo(targetFolderName) if (!dockerInfo || !isAllowedContainerName(dockerInfo.containerKey)) throw new Error('모듈 폴더명 형식이 올바르지 않습니다.') const existsOutput = await runSshOutput(target, [ `ROOT=${quoteShellSingle(target.workspaceRoot)}`, ...expandRemotePathVar('ROOT'), `FOLDER=${quoteShellSingle(targetFolderName)}`, 'MODULE_PATH="$ROOT/$FOLDER"', '[ -d "$MODULE_PATH" ] && printf yes || printf no' ].join('\n'), 8000) if (existsOutput.trim() !== 'yes') throw new Error('설치된 모듈을 찾을 수 없습니다.') const nav2Module = isNav2ModuleFolder(targetFolderName) const runtimeImage = nav2Module ? await resolveRemoteCustomModuleRuntimeImage( target, targetFolderName, await resolveRemoteNav2RuntimeImage(runtimeImages), ) : null const runtimeRoot = nav2Module ? await ensureRemoteNav2RuntimeAssets(target) : await ensureRemoteStudioRuntimeAssets(target) const fastDdsConfigPath = await prepareRemoteRosNetworkConfig(target, runtimeRoot, rosNetwork, rosDomainId) await prepareRemoteRuntimeImages(target, runtimeImages) const currentStatus = await runSshOutput(target, `timeout 12s docker ps -a --filter ${quoteShellSingle(`name=^/${dockerInfo.containerKey}$`)} --format "{{.Status}}" 2>/dev/null || true`, 20000) if (/^Up\b/i.test(currentStatus.trim())) return { ok: true, message: '이미 실행 중입니다.' } if (currentStatus.trim()) { await runSsh(target, `docker rm -f ${quoteShellSingle(dockerInfo.containerKey)} >/dev/null 2>&1 || true`, 10000) } const remoteRosParams = typeof rosParamsOverride === 'string' ? rosParamsOverride.trim() : await runSshOutput(target, [ `ROOT=${quoteShellSingle(target.workspaceRoot)}`, ...expandRemotePathVar('ROOT'), `FOLDER=${quoteShellSingle(targetFolderName)}`, 'MODULE_PATH="$ROOT/$FOLDER"', 'RUN_SH="$MODULE_PATH/run.sh"', 'PARAMS=$([ -f "$RUN_SH" ] && grep -E "^ROS_PARAM=" "$RUN_SH" | head -n 1 || true)', 'PARAMS=${PARAMS#ROS_PARAM=}', "PARAMS=${PARAMS#\\'}", "PARAMS=${PARAMS%\\'}", 'PARAMS=${PARAMS#\\"}', 'PARAMS=${PARAMS%\\"}', 'printf "%s" "$PARAMS"' ].join('\n'), 8000) .then((output) => output.trim()) await runSsh( target, [ 'set -e', `ROOT=${quoteShellSingle(target.workspaceRoot)}`, ...expandRemotePathVar('ROOT'), `FOLDER=${quoteShellSingle(targetFolderName)}`, 'MODULE_PATH="$ROOT/$FOLDER"', 'true' ].join('\n'), 30000 ) await runSsh( target, nav2Module ? [ 'set -e', `RUNTIME_ROOT=${quoteShellSingle(runtimeRoot)}`, ...expandRemotePathVar('RUNTIME_ROOT'), `ROOT=${quoteShellSingle(target.workspaceRoot)}`, ...expandRemotePathVar('ROOT'), `PARAMS=${quoteShellSingle(remoteRosParams)}`, `FASTDDS_CONFIG_PATH=${quoteShellSingle(fastDdsConfigPath ?? '')}`, `ROS_LOCALHOST_ONLY=${rosNetworkEnabled(rosNetwork) ? '0' : '1'}`, ...expandRemotePathVar('FASTDDS_CONFIG_PATH'), 'read -r -a EXTRA_ARGS <<< "$PARAMS"', 'export FASTDDS_CONFIG_PATH', 'export ROS_LOCALHOST_ONLY', `bash "$RUNTIME_ROOT/docker/docker_run_nav2.sh" ${quoteShellSingle(dockerInfo.containerKey)} "$ROOT" ${quoteShellSingle(targetFolderName)} ${quoteShellSingle(runtimeImage!)} ${quoteShellSingle(String(rosDomainId ?? process.env.ROS_DOMAIN_ID ?? '0').trim() || '0')} ${gpuEnabled ? 'true' : 'false'} "\${EXTRA_ARGS[@]}"` ].join('\n') : [ 'set -e', `RUNTIME_ROOT=${quoteShellSingle(runtimeRoot)}`, ...expandRemotePathVar('RUNTIME_ROOT'), `ROOT=${quoteShellSingle(target.workspaceRoot)}`, ...expandRemotePathVar('ROOT'), 'SCRIPT_PATH="$RUNTIME_ROOT/docker/docker_run_autoware-universe:humble-20240301-cuda.sh"', 'cd "$RUNTIME_ROOT"', `SHARD_DIR="$ROOT" bash "$SCRIPT_PATH" ${quoteShellSingle(dockerInfo.containerKey)} ${quoteShellSingle(targetFolderName)} localhost localhost ${quoteShellSingle(autowareDockerImage)} ${quoteShellSingle(remoteRosParams)} ${quoteShellSingle(String(rosDomainId ?? process.env.ROS_DOMAIN_ID ?? '0').trim() || '0')} ${gpuEnabled ? 'true' : 'false'} false` ].join('\n'), 30000 ) return { ok: true, message: '실행 요청을 보냈습니다.' } } async function buildRemoteWorkspaceModule( targetInput: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, _gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null, ): Promise<{ ok: boolean; message: string }> { const target = normalizeRemoteTarget(targetInput) const targetFolderName = folderName?.trim() || moduleName const dockerInfo = parseModuleDockerInfo(targetFolderName) if (!dockerInfo || !isAllowedContainerName(dockerInfo.containerKey)) throw new Error('모듈 폴더명 형식이 올바르지 않습니다.') if (isNav2ModuleFolder(targetFolderName)) { const runtimeRoot = await ensureRemoteNav2RuntimeAssets(target) const runtimeImage = await resolveRemoteCustomModuleRuntimeImage( target, targetFolderName, await resolveRemoteNav2RuntimeImage(runtimeImages), ) await prepareRemoteRuntimeImages(target, runtimeImages) await runSsh(target, [ 'set -e', `RUNTIME_ROOT=${quoteShellSingle(runtimeRoot)}`, ...expandRemotePathVar('RUNTIME_ROOT'), `ROOT=${quoteShellSingle(target.workspaceRoot)}`, ...expandRemotePathVar('ROOT'), `bash "$RUNTIME_ROOT/docker/docker_build_nav2.sh" "$ROOT" ${quoteShellSingle(targetFolderName)} ${quoteShellSingle(runtimeImage)} ${quoteShellSingle(String(rosDomainId ?? process.env.ROS_DOMAIN_ID ?? '0').trim() || '0')}` ].join('\n'), 900000) return { ok: true, message: '빌드가 완료되었습니다.' } } await runSsh(target, [ 'set -e', `ROOT=${quoteShellSingle(target.workspaceRoot)}`, ...expandRemotePathVar('ROOT'), `FOLDER=${quoteShellSingle(targetFolderName)}`, 'MODULE_PATH="$ROOT/$FOLDER"', '[ -d "$MODULE_PATH" ] || { echo "설치된 모듈을 찾을 수 없습니다." >&2; exit 1; }', `BUILD_CONTAINER=${quoteShellSingle(`${dockerInfo.containerKey}-build`)}`, `ROS_DOMAIN_ID_VALUE=${quoteShellSingle(String(rosDomainId ?? process.env.ROS_DOMAIN_ID ?? '0').trim() || '0')}`, 'docker rm -f "$BUILD_CONTAINER" >/dev/null 2>&1 || true', 'docker run --rm \\', ' --name "$BUILD_CONTAINER" \\', ' --volume="$MODULE_PATH:/autoware:rw" \\', ' -u root \\', ' --net=host \\', ' -e ROS_DOMAIN_ID="$ROS_DOMAIN_ID_VALUE" \\', ' -v /etc/localtime:/etc/localtime:ro \\', ' --entrypoint /bin/bash \\', ` ${quoteShellSingle(autowareDockerImage)} -lc "cd /autoware && source /opt/ros/humble/setup.bash && colcon build --symlink-install"` ].join('\n'), 900000) return { ok: true, message: '빌드가 완료되었습니다.' } } async function stopWorkspaceModule( moduleName: string, workspaceRoot?: string | null, folderName?: string | null, ): Promise<{ ok: boolean; message: string }> { const targetFolderName = folderName?.trim() if (!targetFolderName) throw new Error('모듈 폴더명이 없습니다.') const dockerInfo = parseModuleDockerInfo(targetFolderName) if (!dockerInfo || !isAllowedContainerName(dockerInfo.containerKey)) throw new Error('모듈 폴더명 형식이 올바르지 않습니다.') const moduleRoot = await findWorkspaceModuleRoot(moduleName, workspaceRoot, targetFolderName) if (!moduleRoot) throw new Error('설치된 모듈을 찾을 수 없습니다.') const statuses = await readDockerStatuses().catch(() => new Map()) const current = statuses.get(dockerInfo.containerKey) const buildContainer = `${dockerInfo.containerKey}-build` const currentBuild = statuses.get(buildContainer) if (currentBuild?.exists) { await runCommand('docker', ['rm', '-f', buildContainer], undefined, undefined, 30000).catch(() => undefined) } if (!current?.exists) return { ok: true, message: currentBuild?.exists ? '빌드를 중지했습니다.' : '중지할 컨테이너가 없습니다.' } await runCommand('docker', ['stop', dockerInfo.containerKey], undefined, undefined, 30000).catch(async (error) => { if (current.running) throw error }) return { ok: true, message: '중지했습니다.' } } async function stopRemoteWorkspaceModule( targetInput: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, ): Promise<{ ok: boolean; message: string }> { const target = normalizeRemoteTarget(targetInput) const targetFolderName = folderName?.trim() || moduleName?.trim() const containerKey = remoteContainerKey(targetFolderName) if (!containerKey || !isAllowedContainerName(containerKey)) throw new Error('모듈 폴더명 형식이 올바르지 않습니다.') const [currentStatus, buildStatus] = await Promise.all([ runSshOutput(target, `timeout 12s docker ps -a --filter ${quoteShellSingle(`name=^/${containerKey}$`)} --format "{{.Status}}" 2>/dev/null || true`, 20000), runSshOutput(target, `timeout 12s docker ps -a --filter ${quoteShellSingle(`name=^/${containerKey}-build$`)} --format "{{.Status}}" 2>/dev/null || true`, 20000), ]) if (buildStatus.trim()) await runSsh(target, `docker rm -f ${quoteShellSingle(`${containerKey}-build`)} >/dev/null 2>&1 || true`, 30000) if (!currentStatus.trim()) return { ok: true, message: buildStatus.trim() ? '빌드를 중지했습니다.' : '중지할 컨테이너가 없습니다.' } if (/^Up\b/i.test(currentStatus.trim())) { await runSsh(target, `docker stop ${quoteShellSingle(containerKey)}`, 30000) } return { ok: true, message: '중지했습니다.' } } async function stopWorkspaceModules( modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string | null, ): Promise<{ ok: boolean; message: string }> { try { const statuses = await readDockerStatuses().catch(() => new Map()) const containerKeys = new Set() for (const module of modules) { const targetFolderName = module.folderName?.trim() if (!targetFolderName) continue const dockerInfo = parseModuleDockerInfo(targetFolderName) if (!dockerInfo || !isAllowedContainerName(dockerInfo.containerKey) || containerKeys.has(dockerInfo.containerKey)) continue const moduleRoot = await findWorkspaceModuleRoot(module.moduleName, workspaceRoot, targetFolderName) if (!moduleRoot) continue const current = statuses.get(dockerInfo.containerKey) if (current?.running) containerKeys.add(dockerInfo.containerKey) } if (!containerKeys.size) return { ok: true, message: '중지할 컨테이너가 없습니다.' } await runCommand('docker', ['stop', ...Array.from(containerKeys)], undefined, undefined, 30000) return { ok: true, message: '중지했습니다.' } } catch (error) { return { ok: false, message: error instanceof Error ? error.message : String(error) } } } async function stopRemoteWorkspaceModules( targetInput: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>, ): Promise<{ ok: boolean; message: string }> { try { const target = normalizeRemoteTarget(targetInput) const containerKeys = Array.from(new Set(modules .map((module) => remoteContainerKey(module.folderName)) .filter((key): key is string => Boolean(key && isAllowedContainerName(key))))) if (!containerKeys.length) return { ok: true, message: '중지할 컨테이너가 없습니다.' } const statusOutput = await runSshOutput(target, 'timeout 12s docker ps --format "{{.Names}}" 2>/dev/null || true', 20000) const running = new Set(statusOutput.split('\n').map((line) => line.trim()).filter(Boolean)) const targets = containerKeys.filter((containerKey) => running.has(containerKey)) if (!targets.length) return { ok: true, message: '중지할 컨테이너가 없습니다.' } await runSsh(target, `docker stop ${targets.map(quoteShellSingle).join(' ')}`, 30000) return { ok: true, message: '중지했습니다.' } } catch (error) { return { ok: false, message: error instanceof Error ? error.message : String(error) } } } async function resetRemoteRosSession( targetInput: RemoteWorkspaceTarget, ): Promise<{ ok: boolean; message: string }> { try { const target = normalizeRemoteTarget(targetInput) await runSsh(target, [ 'set -e', // Module containers are always named from their LMS module folder. Remove every // one so a prior control session cannot keep publishing on the shared domain. 'MODULE_CONTAINERS="$(docker ps -a --format \"{{.ID}} {{.Names}}\" 2>/dev/null | awk \'$2 ~ /^lms-/ { print $1 }\')"', 'if [ -n "$MODULE_CONTAINERS" ]; then docker rm -f $MODULE_CONTAINERS >/dev/null 2>&1 || true; fi', 'docker rm -f nav2-ros-bridge autoware-ros-bridge slam-ros-bridge >/dev/null 2>&1 || true', 'find "$HOME/autonomy-studio/runtime" "$HOME/.autonomy-studio/runtime" -type f -path "*/docker/fastdds-peer-*.xml" -delete 2>/dev/null || true', ].join('\n'), 45_000) return { ok: true, message: '원격 ROS 연결을 초기화했습니다.' } } catch (error) { return { ok: false, message: error instanceof Error ? error.message : String(error) } } } async function resetRemoteWorkspaceSession( targetInput: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null; templateRepositoryUrl?: string | null }>, ): Promise<{ ok: boolean; message: string }> { try { const target = normalizeRemoteTarget(targetInput) await stopRemoteWorkspaceModules(target, modules).catch(() => ({ ok: false, message: '모듈 중지 실패' })) const commands = [ 'set -e', `ROOT=${quoteShellSingle(target.workspaceRoot)}`, ...expandRemotePathVar('ROOT'), ] for (const module of modules) { const folderName = module.folderName?.trim() const templateRepositoryUrl = module.templateRepositoryUrl?.trim() if (!folderName || !templateRepositoryUrl) continue const templateRepositoryWithCredential = withGitCredential(remoteRepositoryUrl(templateRepositoryUrl)) commands.push( `MODULE="$ROOT/${folderName}"`, 'if [ -d "$MODULE/.git" ]; then', ' git -C "$MODULE" remote remove student >/dev/null 2>&1 || true', ` git -C "$MODULE" remote set-url origin ${quoteShellSingle(templateRepositoryWithCredential)}`, ' git -C "$MODULE" fetch origin --prune', ' git -C "$MODULE" checkout -B main origin/main', ' git -C "$MODULE" reset --hard origin/main', ' git -C "$MODULE" clean -fdx', 'fi', ) } await runSsh(target, commands.join('\n'), 180000) return { ok: true, message: '템플릿 기준으로 초기화했습니다.' } } catch (error) { return { ok: false, message: error instanceof Error ? error.message : String(error) } } } async function restartWorkspaceModule( moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParamsOverride?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null, ): Promise<{ ok: boolean; message: string }> { await stopWorkspaceModule(moduleName, workspaceRoot, folderName) return startWorkspaceModule(moduleName, workspaceRoot, folderName, rosParamsOverride, gpuEnabled, rosDomainId, runtimeImages, rosNetwork) } async function restartRemoteWorkspaceModule( target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, rosParamsOverride?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null, ): Promise<{ ok: boolean; message: string }> { await stopRemoteWorkspaceModule(target, moduleName, folderName) return startRemoteWorkspaceModule(target, moduleName, folderName, rosParamsOverride, gpuEnabled, rosDomainId, runtimeImages, rosNetwork) } async function readWorkspaceModuleLogs( moduleName: string, workspaceRoot?: string | null, folderName?: string | null, ): Promise<{ ok: boolean; logs: string; message?: string }> { const targetFolderName = folderName?.trim() if (!targetFolderName) throw new Error('모듈 폴더명이 없습니다.') const dockerInfo = parseModuleDockerInfo(targetFolderName) if (!dockerInfo || !isAllowedContainerName(dockerInfo.containerKey)) throw new Error('모듈 폴더명 형식이 올바르지 않습니다.') const moduleRoot = await findWorkspaceModuleRoot(moduleName, workspaceRoot, targetFolderName) if (!moduleRoot) throw new Error('설치된 모듈을 찾을 수 없습니다.') const statuses = await readDockerStatuses().catch(() => new Map()) if (!statuses.get(dockerInfo.containerKey)?.exists) { return { ok: true, logs: '', message: '생성된 컨테이너가 없습니다.' } } try { const logs = await runCommandOutput('docker', ['logs', '--timestamps', '--tail', '300', dockerInfo.containerKey], undefined, undefined, 10000) return { ok: true, logs } } catch (error) { return { ok: false, logs: '', message: error instanceof Error ? error.message : String(error) } } } async function readRemoteWorkspaceModuleLogs( targetInput: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, ): Promise<{ ok: boolean; logs: string; message?: string }> { const target = normalizeRemoteTarget(targetInput) const targetFolderName = folderName?.trim() || moduleName?.trim() const containerKey = remoteContainerKey(targetFolderName) if (!containerKey || !isAllowedContainerName(containerKey)) throw new Error('모듈 폴더명 형식이 올바르지 않습니다.') const existsOutput = await runSshOutput(target, `timeout 12s docker ps -a --filter ${quoteShellSingle(`name=^/${containerKey}$`)} --format "{{.Names}}" 2>/dev/null || true`, 20000) if (!existsOutput.trim()) return { ok: true, logs: '', message: '생성된 컨테이너가 없습니다.' } try { const logs = await runSshOutput(target, `docker logs --timestamps --tail 300 ${quoteShellSingle(containerKey)}`, 10000) return { ok: true, logs } } catch (error) { return { ok: false, logs: '', message: error instanceof Error ? error.message : String(error) } } } async function collectModuleFiles(moduleName: string, workspaceRoot?: string | null, folderName?: string | null): Promise { const rootPath = await findModuleRoot(moduleName, workspaceRoot, folderName) if (!rootPath) return { root: null, absoluteRoot: null, files: {}, folders: [] } const files: Record = {} const folders: string[] = [] const visit = async (currentPath: string, depth: number): Promise => { if (depth > 10) return const entries = await readdir(currentPath, { withFileTypes: true }) for (const entry of entries) { if (entry.name.startsWith('.') || skippedCacheDirs.has(entry.name)) continue const absolutePath = resolve(currentPath, entry.name) if (!absolutePath.startsWith(rootPath)) continue if (entry.isDirectory()) { folders.push(toRendererModulePath(moduleName, absolutePath, rootPath)) await visit(absolutePath, depth + 1) continue } if (!entry.isFile()) continue const filePath = toRendererModulePath(moduleName, absolutePath, rootPath) const fileStat = await stat(absolutePath) if (!textFilePattern.test(entry.name) || fileStat.size > 1024 * 1024) { files[filePath] = 'Binary or large file preview is not available.' continue } files[filePath] = await readFile(absolutePath, 'utf8') } } await visit(rootPath, 0) return { root: basename(rootPath), absoluteRoot: rootPath, files, folders } } async function collectRemoteModuleFiles(targetInput: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null): Promise { const { target, moduleRoot } = await resolveRemoteGitModuleRoot(targetInput, moduleName, folderName) const skippedNames = Array.from(skippedCacheDirs).map(quoteShellSingle).join(' ') const script = [ 'set +e', `ROOT=${quoteShellSingle(moduleRoot)}`, `SKIPPED="${skippedNames}"`, 'find "$ROOT" -mindepth 1 -maxdepth 10 \\( -name .git -o -name build -o -name install -o -name log -o -name node_modules -o -name docker \\) -prune -o -type d -printf "D\\t%P\\n" -o -type f -printf "F\\t%P\\t%s\\n" | while IFS="$(printf \'\\t\')" read -r KIND REL SIZE; do', ' [ -z "$REL" ] && continue', ' NAME="${REL##*/}"', ' case "$KIND" in', ' D) printf "D\\t%s\\n" "$(printf %s "$REL" | base64 -w0)" ;;', ' F)', ' CONTENT="QmluYXJ5IG9yIGxhcmdlIGZpbGUgcHJldmlldyBpcyBub3QgYXZhaWxhYmxlLg=="', ' if [ "${SIZE:-0}" -le 1048576 ]; then', ' case "$NAME" in', ' *.c|*.cc|*.cpp|*.cxx|*.h|*.hpp|*.hh|*.py|*.xml|*.urdf|*.xacro|*.yaml|*.yml|*.json|*.txt|*.md|*.cmake|*.launch|*.sh|*.bash|*.toml|*.ini|*.cfg|*.rviz) CONTENT="$(base64 -w0 "$ROOT/$REL" 2>/dev/null)" ;;', ' esac', ' fi', ' printf "F\\t%s\\t%s\\n" "$(printf %s "$REL" | base64 -w0)" "$CONTENT"', ' ;;', ' esac', 'done' ].join('\n') // Source transfers must not hold up module start/stop on the control queue. const output = await runUnqueuedSshOutput(target, script, 30000) const files: Record = {} const folders: string[] = [] for (const line of output.split('\n')) { if (!line.trim()) continue const [kind, encodedPath = '', encodedContent = ''] = line.split('\t') const relativePath = decodeRemoteField(encodedPath) if (!relativePath) continue const rendererPath = `modules/${moduleName}/${relativePath}` if (kind === 'D') folders.push(rendererPath) if (kind === 'F') files[rendererPath] = decodeRemoteField(encodedContent) } return { root: moduleRoot.split('/').pop() ?? moduleName, absoluteRoot: moduleRoot, files, folders } } async function collectRootFiles(rootPath: string, rendererRoot: string): Promise { const files: Record = {} const folders: string[] = [] const visit = async (currentPath: string, depth: number): Promise => { if (depth > 10) return const entries = await readdir(currentPath, { withFileTypes: true }) for (const entry of entries) { if (entry.name.startsWith('.') || skippedCacheDirs.has(entry.name)) continue const absolutePath = resolve(currentPath, entry.name) if (!absolutePath.startsWith(rootPath)) continue if (entry.isDirectory()) { folders.push(toRendererRootPath(rendererRoot, absolutePath, rootPath)) await visit(absolutePath, depth + 1) continue } if (!entry.isFile()) continue const filePath = toRendererRootPath(rendererRoot, absolutePath, rootPath) const fileStat = await stat(absolutePath) if (!textFilePattern.test(entry.name) || fileStat.size > 1024 * 1024) { files[filePath] = 'Binary or large file preview is not available.' continue } files[filePath] = await readFile(absolutePath, 'utf8') } } await visit(rootPath, 0) return { root: basename(rootPath), absoluteRoot: rootPath, files, folders } } async function resolveRendererModulePath(moduleName: string, rendererPath: string, workspaceRoot?: string | null, folderName?: string | null): Promise { const moduleRoot = await findModuleRoot(moduleName, workspaceRoot, folderName) if (!moduleRoot) throw new Error('Module is not installed in workspace') const rendererRoot = `modules/${moduleName}` const relativePath = rendererPath === rendererRoot ? '' : rendererPath.startsWith(`${rendererRoot}/`) ? rendererPath.slice(rendererRoot.length + 1) : rendererPath const absolutePath = resolve(moduleRoot, relativePath) const relativeToRoot = relative(moduleRoot, absolutePath) if (relativeToRoot.startsWith('..') || relativeToRoot === '..' || resolve(relativeToRoot) === relativeToRoot) { throw new Error('Invalid module path') } return absolutePath } async function resolveRemoteRendererModulePath(targetInput: RemoteWorkspaceTarget, moduleName: string, rendererPath: string, folderName?: string | null): Promise<{ target: ReturnType; moduleRoot: string; relativePath: string; absolutePath: string }> { const { target, moduleRoot } = await resolveRemoteGitModuleRoot(targetInput, moduleName, folderName) const rendererRoot = `modules/${moduleName}` const relativePath = rendererPath === rendererRoot ? '' : rendererPath.startsWith(`${rendererRoot}/`) ? rendererPath.slice(rendererRoot.length + 1) : rendererPath if (relativePath.split('/').some((part) => part === '..') || relativePath.startsWith('/')) { throw new Error('Invalid module path') } return { target, moduleRoot, relativePath, absolutePath: relativePath ? remotePathJoin(moduleRoot, relativePath) : moduleRoot } } function imageMimeType(filePath: string): string { const extension = filePath.split('.').pop()?.toLowerCase() if (extension === 'png') return 'image/png' if (extension === 'jpg' || extension === 'jpeg') return 'image/jpeg' if (extension === 'webp') return 'image/webp' if (extension === 'gif') return 'image/gif' if (extension === 'svg') return 'image/svg+xml' throw new Error('지원하지 않는 이미지 형식입니다.') } async function readWorkspaceModuleImage(moduleName: string, rendererPath: string, workspaceRoot?: string | null, folderName?: string | null): Promise<{ dataUrl: string }> { if (!imageFilePattern.test(rendererPath)) throw new Error('지원하지 않는 이미지 형식입니다.') const targetPath = await resolveRendererModulePath(moduleName, rendererPath, workspaceRoot, folderName) const fileInfo = await stat(targetPath) if (fileInfo.size > maxImagePreviewBytes) throw new Error('이미지는 10MB 이하만 미리볼 수 있습니다.') const content = await readFile(targetPath) return { dataUrl: `data:${imageMimeType(rendererPath)};base64,${content.toString('base64')}` } } async function readRemoteWorkspaceModuleImage(targetInput: RemoteWorkspaceTarget, moduleName: string, rendererPath: string, folderName?: string | null): Promise<{ dataUrl: string }> { if (!imageFilePattern.test(rendererPath)) throw new Error('지원하지 않는 이미지 형식입니다.') const resolved = await resolveRemoteRendererModulePath(targetInput, moduleName, rendererPath, folderName) const script = [ `FILE=${quoteShellSingle(resolved.absolutePath)}`, 'test -f "$FILE"', 'SIZE=$(wc -c < "$FILE")', `test "$SIZE" -le ${maxImagePreviewBytes}`, 'base64 -w0 "$FILE"', ].join(' && ') const encoded = (await runSshOutput(resolved.target, script, 30000)).trim() if (!encoded) throw new Error('이미지 내용을 읽을 수 없습니다.') return { dataUrl: `data:${imageMimeType(rendererPath)};base64,${encoded}` } } function getTerminalKey(webContentsId: number, sessionId: string): string { return `${webContentsId}:${sessionId}` } function quoteShellArg(value: string): string { return `'${value.replace(/'/g, `'\\''`)}'` } function buildWorkspaceShellCommand( moduleName: string | undefined, terminalCwd: string, cols: number, rows: number, restrictToModuleWorkspace: boolean, ): string { const promptLabel = (moduleName?.trim() || basename(terminalCwd)).replace(/[^a-zA-Z0-9_.-]+/g, '-') const navigationCommands = restrictToModuleWorkspace ? [ '__module_workspace_guard() { if [[ "$PWD" == "$MODULE_WORKSPACE_ROOT" || "$PWD" == "$MODULE_WORKSPACE_ROOT"/* ]]; then return 0; fi; builtin cd "$MODULE_WORKSPACE_ROOT"; printf "\\n모듈 워크스페이스 밖으로 이동할 수 없습니다.\\n"; return 1; }', 'cd() { builtin cd "$@" || return; __module_workspace_guard; }', 'export PROMPT_COMMAND=\'__module_workspace_guard; if [[ "$PWD" == "$MODULE_WORKSPACE_ROOT" ]]; then __rel=""; else __rel="${PWD:$(( ${#MODULE_WORKSPACE_ROOT} + 1 ))}"; fi; PS1="\\[\\e[1;34m\\]${MODULE_PROMPT_LABEL}${__rel:+/$__rel}\\[\\e[0m\\]: "\'', 'export -f __module_workspace_guard cd', ] : [ 'export PROMPT_COMMAND=\'if [[ "$PWD" == "$MODULE_WORKSPACE_ROOT" ]]; then __rel=""; elif [[ "$PWD" == "$MODULE_WORKSPACE_ROOT"/* ]]; then __rel="${PWD:$(( ${#MODULE_WORKSPACE_ROOT} + 1 ))}"; else __rel=".../${PWD##*/}"; fi; PS1="\\[\\e[1;34m\\]${MODULE_PROMPT_LABEL}${__rel:+/$__rel}\\[\\e[0m\\]: "\'', ] const initCommands = [ `stty cols ${cols} rows ${rows}`, `export MODULE_WORKSPACE_ROOT=${quoteShellArg(terminalCwd)}`, `export MODULE_PROMPT_LABEL=${quoteShellArg(promptLabel)}`, `cd ${quoteShellArg(terminalCwd)}`, 'ls() { command ls --color=auto "$@"; }', 'grep() { command grep --color=auto "$@"; }', 'egrep() { command egrep --color=auto "$@"; }', 'fgrep() { command fgrep --color=auto "$@"; }', 'export -f ls grep egrep fgrep', ...navigationCommands, 'exec /bin/bash --noprofile --norc -i' ] return `/bin/bash --noprofile --norc -c ${quoteShellArg(initCommands.join('; '))}` } function stopTerminal(terminalKey: string): void { const timer = terminalStopTimers.get(terminalKey) if (timer) clearTimeout(timer) terminalStopTimers.delete(terminalKey) const proc = terminalProcesses.get(terminalKey) if (!proc) return intentionalTerminalStops.add(terminalKey) terminalProcesses.delete(terminalKey) proc.kill() } function stopTerminalsForWebContents(webContentsId: number): void { const keyPrefix = `${webContentsId}:` for (const terminalKey of terminalProcesses.keys()) { if (terminalKey.startsWith(keyPrefix)) stopTerminal(terminalKey) } } function cancelStopTerminal(terminalKey: string): void { const timer = terminalStopTimers.get(terminalKey) if (!timer) return clearTimeout(timer) terminalStopTimers.delete(terminalKey) } function writeHiddenTerminalCommand(sender: WebContents, sessionId: string, proc: IPty, command: string): void { sender.send('terminal-suppress-data', sessionId, `${command}\r\n`) sender.send('terminal-suppress-data', sessionId, `${command}\n`) proc.write(`${command}\n`) } function resizeTerminal(sender: WebContents, sessionId: string, cols?: number, rows?: number): boolean { const proc = terminalProcesses.get(getTerminalKey(sender.id, sessionId)) if (!proc) return false const safeCols = Math.max(20, Math.min(300, Math.floor(cols ?? 80))) const safeRows = Math.max(5, Math.min(120, Math.floor(rows ?? 24))) proc.resize(safeCols, safeRows) return true } async function resolveModuleTerminalRoot(moduleName?: string, workspaceRoot?: string | null, folderName?: string | null): Promise { if (!moduleName) return moduleCacheRoot const moduleRoot = await findModuleRoot(moduleName, workspaceRoot, folderName) if (!moduleRoot) throw new Error('Module is not installed in workspace') return moduleRoot } function terminalEnvironment(cols: number, rows: number): Record { return Object.fromEntries( Object.entries({ ...process.env, TERM: 'xterm-256color', COLORTERM: 'truecolor', COLUMNS: String(cols), LINES: String(rows) }).filter((entry): entry is [string, string] => typeof entry[1] === 'string') ) } function attachTerminalProcess(sender: WebContents, sessionId: string, terminalKey: string, proc: IPty): void { terminalProcesses.set(terminalKey, proc) proc.onData((data) => { if (!sender.isDestroyed()) sender.send('terminal-data', sessionId, data) }) proc.onExit(({ exitCode }) => { terminalProcesses.delete(terminalKey) if (intentionalTerminalStops.delete(terminalKey)) return if (!sender.isDestroyed()) sender.send('terminal-data', sessionId, `\r\n[terminal exited: ${exitCode}]\r\n`) }) } function registerTerminalHandlers(): void { ipcMain.handle('terminal-start', async (event, moduleName?: string, cols?: number, rows?: number, sessionId = 'default', workspaceRoot?: string | null, folderName?: string | null) => { const webContentsId = event.sender.id const terminalKey = getTerminalKey(webContentsId, sessionId) cancelStopTerminal(terminalKey) const terminalCwd = await resolveModuleTerminalRoot(moduleName, workspaceRoot, folderName).catch((error) => { event.sender.send('terminal-data', sessionId, `\r\n[terminal error: ${error instanceof Error ? error.message : 'workspace not found'}]\r\n`) return null }) if (!terminalCwd) return false await mkdir(terminalCwd, { recursive: true }) const existingProc = terminalProcesses.get(terminalKey) if (existingProc) { return true } const safeCols = Math.max(20, Math.min(300, Math.floor(cols ?? 80))) const safeRows = Math.max(5, Math.min(120, Math.floor(rows ?? 24))) const proc = pty.spawn('/bin/bash', ['--noprofile', '--norc', '-c', buildWorkspaceShellCommand(moduleName, terminalCwd, safeCols, safeRows, false)], { cwd: terminalCwd, name: 'xterm-256color', cols: safeCols, rows: safeRows, env: terminalEnvironment(safeCols, safeRows) }) attachTerminalProcess(event.sender, sessionId, terminalKey, proc) event.sender.once('destroyed', () => stopTerminalsForWebContents(webContentsId)) return true }) ipcMain.handle('local-workspace:terminal:start', async (event, moduleName?: string, cols?: number, rows?: number, sessionId = 'default', workspaceRoot?: string | null, folderName?: string | null) => { const webContentsId = event.sender.id const terminalKey = getTerminalKey(webContentsId, sessionId) cancelStopTerminal(terminalKey) const terminalCwd = await resolveModuleTerminalRoot(moduleName, workspaceRoot, folderName).catch((error) => { event.sender.send('terminal-data', sessionId, `\r\n[terminal error: ${error instanceof Error ? error.message : 'workspace not found'}]\r\n`) return null }) if (!terminalCwd) return false await mkdir(terminalCwd, { recursive: true }) const existingProc = terminalProcesses.get(terminalKey) if (existingProc) { return true } const safeCols = Math.max(20, Math.min(300, Math.floor(cols ?? 80))) const safeRows = Math.max(5, Math.min(120, Math.floor(rows ?? 24))) const proc = pty.spawn('/bin/bash', ['--noprofile', '--norc', '-c', buildWorkspaceShellCommand(moduleName, terminalCwd, safeCols, safeRows, false)], { cwd: terminalCwd, name: 'xterm-256color', cols: safeCols, rows: safeRows, env: terminalEnvironment(safeCols, safeRows) }) attachTerminalProcess(event.sender, sessionId, terminalKey, proc) event.sender.once('destroyed', () => stopTerminalsForWebContents(webContentsId)) return true }) ipcMain.handle('remote-workspace:terminal:start', async (event, targetInput: RemoteWorkspaceTarget, moduleName?: string, cols?: number, rows?: number, sessionId = 'default', folderName?: string | null) => { const webContentsId = event.sender.id const terminalKey = getTerminalKey(webContentsId, sessionId) cancelStopTerminal(terminalKey) const resolved = await (moduleName ? resolveRemoteGitModuleRoot(targetInput, moduleName, folderName) : Promise.resolve({ target: normalizeRemoteTarget(targetInput), moduleRoot: normalizeRemoteTarget(targetInput).workspaceRoot }) ).catch((error) => { event.sender.send('terminal-data', sessionId, `\r\n[terminal error: ${error instanceof Error ? error.message : 'workspace not found'}]\r\n`) return null }) if (!resolved) return false const existingProc = terminalProcesses.get(terminalKey) if (existingProc) return true const safeCols = Math.max(20, Math.min(300, Math.floor(cols ?? 80))) const safeRows = Math.max(5, Math.min(120, Math.floor(rows ?? 24))) const target = normalizeRemoteTarget(resolved.target) const proc = pty.spawn('ssh', [ '-tt', '-p', String(target.port), '-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=accept-new', '-o', 'ConnectTimeout=5', '-i', target.identityFile, `${target.user}@${target.host}`, buildWorkspaceShellCommand(moduleName, resolved.moduleRoot, safeCols, safeRows, true) ], { name: 'xterm-256color', cols: safeCols, rows: safeRows, env: terminalEnvironment(safeCols, safeRows) }) attachTerminalProcess(event.sender, sessionId, terminalKey, proc) event.sender.once('destroyed', () => stopTerminalsForWebContents(webContentsId)) return true }) ipcMain.handle('terminal-resize', (event, cols?: number, rows?: number, sessionId = 'default') => resizeTerminal(event.sender, sessionId, cols, rows)) ipcMain.handle('local-workspace:terminal:resize', (event, cols?: number, rows?: number, sessionId = 'default') => resizeTerminal(event.sender, sessionId, cols, rows)) ipcMain.handle('remote-workspace:terminal:resize', (event, _target: RemoteWorkspaceTarget, cols?: number, rows?: number, sessionId = 'default') => resizeTerminal(event.sender, sessionId, cols, rows)) ipcMain.on('terminal-write', (event, data: string, sessionId = 'default') => { terminalProcesses.get(getTerminalKey(event.sender.id, sessionId))?.write(data) }) ipcMain.on('local-workspace:terminal:write', (event, data: string, sessionId = 'default') => { terminalProcesses.get(getTerminalKey(event.sender.id, sessionId))?.write(data) }) ipcMain.on('remote-workspace:terminal:write', (event, data: string, sessionId = 'default') => { terminalProcesses.get(getTerminalKey(event.sender.id, sessionId))?.write(data) }) ipcMain.handle('terminal-change-directory', async (event, moduleName: string, sessionId = 'default', workspaceRoot?: string | null, folderName?: string | null) => { const terminalKey = getTerminalKey(event.sender.id, sessionId) cancelStopTerminal(terminalKey) const proc = terminalProcesses.get(terminalKey) if (!proc) return false const terminalCwd = await resolveModuleTerminalRoot(moduleName, workspaceRoot, folderName).catch((error) => { event.sender.send('terminal-data', sessionId, `\r\n[terminal error: ${error instanceof Error ? error.message : 'workspace not found'}]\r\n`) return null }) if (!terminalCwd) return false await mkdir(terminalCwd, { recursive: true }) writeHiddenTerminalCommand(event.sender, sessionId, proc, `cd ${quoteShellArg(terminalCwd)}`) return true }) ipcMain.handle('local-workspace:terminal:change-directory', async (event, moduleName: string, sessionId = 'default', workspaceRoot?: string | null, folderName?: string | null) => { const terminalKey = getTerminalKey(event.sender.id, sessionId) cancelStopTerminal(terminalKey) const proc = terminalProcesses.get(terminalKey) if (!proc) return false const terminalCwd = await resolveModuleTerminalRoot(moduleName, workspaceRoot, folderName).catch((error) => { event.sender.send('terminal-data', sessionId, `\r\n[terminal error: ${error instanceof Error ? error.message : 'workspace not found'}]\r\n`) return null }) if (!terminalCwd) return false await mkdir(terminalCwd, { recursive: true }) writeHiddenTerminalCommand(event.sender, sessionId, proc, `cd ${quoteShellArg(terminalCwd)}`) return true }) ipcMain.handle('remote-workspace:terminal:change-directory', async (event, targetInput: RemoteWorkspaceTarget, moduleName: string, sessionId = 'default', folderName?: string | null) => { const terminalKey = getTerminalKey(event.sender.id, sessionId) cancelStopTerminal(terminalKey) const proc = terminalProcesses.get(terminalKey) if (!proc) return false const resolved = await resolveRemoteGitModuleRoot(targetInput, moduleName, folderName).catch((error) => { event.sender.send('terminal-data', sessionId, `\r\n[terminal error: ${error instanceof Error ? error.message : 'workspace not found'}]\r\n`) return null }) if (!resolved) return false writeHiddenTerminalCommand(event.sender, sessionId, proc, `cd ${quoteShellArg(resolved.moduleRoot)}`) return true }) ipcMain.on('terminal-stop', (event, sessionId = 'default') => { stopTerminal(getTerminalKey(event.sender.id, sessionId)) }) ipcMain.on('local-workspace:terminal:stop', (event, sessionId = 'default') => { stopTerminal(getTerminalKey(event.sender.id, sessionId)) }) ipcMain.on('remote-workspace:terminal:stop', (event, sessionId = 'default') => { stopTerminal(getTerminalKey(event.sender.id, sessionId)) }) } if (process.platform === 'linux' && !is.dev) { app.commandLine.appendSwitch('no-sandbox') // Some packaged LMS launches run with an inaccessible /dev/shm. Keep Chromium // shared-memory files under the app temp directory so DevTools can open. app.commandLine.appendSwitch('disable-dev-shm-usage') app.commandLine.appendSwitch('ignore-gpu-blocklist') app.commandLine.appendSwitch('enable-unsafe-swiftshader') } function attachDevToolsShortcut(win: BrowserWindow): void { win.webContents.on('before-input-event', (event, input) => { if (input.type !== 'keyDown') return const key = input.key.toLowerCase() const code = input.code.toLowerCase() // Some Linux keyboard layouts report the physical F12 key as Help. if (input.key === 'F12' || key === 'help' || code === 'f12' || code === 'help' || ((input.control || input.meta) && input.shift && key === 'i')) { event.preventDefault() win.webContents.toggleDevTools() } }) } function suppressSystemContextMenu(win: BrowserWindow): void { type SystemContextMenuWindow = BrowserWindow & { on(event: 'system-context-menu', listener: (event: { preventDefault: () => void }) => void): BrowserWindow } ;(win as SystemContextMenuWindow).on('system-context-menu', (event) => { event.preventDefault() }) } function toggleFocusedDevTools(): void { const target = BrowserWindow.getFocusedWindow() if (!target) return target.webContents.toggleDevTools() } function registerDevToolsShortcuts(): void { globalShortcut.register('CommandOrControl+Shift+I', toggleFocusedDevTools) globalShortcut.register('F12', toggleFocusedDevTools) Menu.setApplicationMenu(Menu.buildFromTemplate([ { label: 'View', submenu: [ { label: 'Toggle Developer Tools', accelerator: 'F12', click: toggleFocusedDevTools }, { label: 'Toggle Developer Tools', accelerator: 'CommandOrControl+Shift+I', click: toggleFocusedDevTools } ] } ])) } function createSplashWindow(): void { splashWindow = new BrowserWindow({ width: 580, height: 380, icon: getAppIconPath(), show: false, frame: false, resizable: false, center: true, autoHideMenuBar: true, backgroundColor: '#10131c', webPreferences: { sandbox: false, backgroundThrottling: false } }) splashVisibleStartedAt = Date.now() splashWindow.once('ready-to-show', () => { splashVisibleStartedAt = Date.now() splashWindow?.show() }) splashWindow.on('closed', () => { if (splashCloseTimer) { clearTimeout(splashCloseTimer) splashCloseTimer = null } splashWindow = null }) splashWindow.loadFile(join(__dirname, '../renderer/splash.html')).catch((error) => { console.error('[splash] load failed', error) }) } function updateSplashStatus(message: string): void { if (!splashWindow || splashWindow.isDestroyed()) return const safeMessage = JSON.stringify(message) splashWindow.webContents.executeJavaScript( `window.setSplashStatus && window.setSplashStatus(${safeMessage})` ).catch(() => undefined) } function closeSplashAndShowMain(): void { if (splashCloseTimer) return const elapsed = splashVisibleStartedAt ? Date.now() - splashVisibleStartedAt : MIN_SPLASH_VISIBLE_MS const remaining = Math.max(0, MIN_SPLASH_VISIBLE_MS - elapsed) if (remaining > 0) { splashCloseTimer = setTimeout(() => { splashCloseTimer = null closeSplashAndShowMain() }, remaining) return } if (mainWindow && !mainWindow.isDestroyed() && !mainWindow.isVisible()) { splashWindow?.close() splashWindow = null mainWindow.show() } } function createWindow(): void { const { width, height } = screen.getPrimaryDisplay().workAreaSize mainWindow = new BrowserWindow({ width: Math.min(1600, Math.floor(width * 0.92)), height: Math.min(940, Math.floor(height * 0.92)), minWidth: 1180, minHeight: 760, title: 'Autonomy Studio LMS', icon: getAppIconPath(), show: false, frame: false, autoHideMenuBar: true, backgroundColor: '#0a0b0e', webPreferences: { preload: join(__dirname, '../preload/index.js'), sandbox: false, devTools: true } }) attachDevToolsShortcut(mainWindow) suppressSystemContextMenu(mainWindow) mainWindow.webContents.on('console-message', (_event, level, message, line, sourceId) => { if (level < 2) return writeRendererDiagnostic('console', `${sourceId}:${line} [level=${level}] ${message}`) }) mainWindow.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL, isMainFrame) => { writeRendererDiagnostic( 'load-failed', `${isMainFrame ? 'main-frame' : 'sub-frame'} code=${errorCode} url=${validatedURL} message=${errorDescription}`, ) }) mainWindow.webContents.on('render-process-gone', (_event, details) => { writeRendererDiagnostic('renderer-gone', `reason=${details.reason} exitCode=${details.exitCode}`) }) mainWindow.once('ready-to-show', () => { setTimeout(() => { updateSplashStatus('로그인 세션 확인 중...') }, 1100) setTimeout(closeSplashAndShowMain, 8000) }) mainWindow.on('closed', () => { mainWindow = null }) if (is.dev && process.env['ELECTRON_RENDERER_URL']) { mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']) } else { mainWindow.loadFile(join(__dirname, '../renderer/index.html')) } } function registerLocalWorkspaceHandlers(): void { ipcMain.on('lms-boot-status', (_event, message: string) => { updateSplashStatus(message) }) ipcMain.on('lms-boot-done', () => { closeSplashAndShowMain() }) ipcMain.handle('local-workspace:modules:list', (_event, workspaceRoot?: string | null, courseModules?: CourseModuleInput[] | null) => collectAvailableModules(workspaceRoot, courseModules)) ipcMain.handle('local-workspace:modules:docker-images', () => listLocalDockerImages()) ipcMain.handle('local-workspace:docker:status', () => checkDockerStatus()) ipcMain.handle('local-workspace:docker:guide', () => shell.openExternal('https://docs.docker.com/engine/install/')) ipcMain.handle('local-workspace:ros-graph', () => collectLocalRosGraph()) ipcMain.handle('local-workspace:modules:install', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => installModuleFromCache(moduleName, workspaceRoot, folderName, templateRepositoryUrl, studentRepositoryUrl, options)) ipcMain.handle('local-workspace:modules:create-custom', (_event, workspaceRoot?: string | null, input?: CustomModuleScaffoldInput | null) => { if (!input) throw new Error('커스텀 모듈 정보가 없습니다.') return createCustomWorkspaceModule(workspaceRoot, input) }) ipcMain.handle('local-workspace:modules:select-import-source', () => selectCustomModuleImportSource()) ipcMain.handle('local-workspace:modules:import-custom', (_event, workspaceRoot?: string | null, input?: CustomModuleImportInput | null) => { if (!input) throw new Error('불러올 모듈 정보가 없습니다.') return importCustomWorkspaceModule(workspaceRoot, input) }) ipcMain.handle('local-workspace:modules:delete', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => deleteWorkspaceModule(moduleName, workspaceRoot, folderName)) ipcMain.handle('local-workspace:modules:start', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => startWorkspaceModule(moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork)) ipcMain.handle('local-workspace:modules:build', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null, rosParams?: string | null) => buildWorkspaceModule(moduleName, workspaceRoot, folderName, gpuEnabled, rosDomainId, runtimeImages, rosParams)) ipcMain.handle('local-workspace:modules:stop', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => stopWorkspaceModule(moduleName, workspaceRoot, folderName)) ipcMain.handle('local-workspace:modules:stop-many', (_event, modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string | null) => stopWorkspaceModules(modules, workspaceRoot)) ipcMain.handle('local-workspace:modules:stop-running', () => stopRunningWorkspaceContainers()) ipcMain.handle('local-workspace:modules:restart', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => restartWorkspaceModule(moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork)) ipcMain.handle('local-workspace:modules:logs', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => readWorkspaceModuleLogs(moduleName, workspaceRoot, folderName)) ipcMain.handle('local-workspace:modules:update-parameters', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string) => updateWorkspaceModuleParameters(moduleName, workspaceRoot, folderName, rosParams)) ipcMain.handle('local-workspace:nav2:set-simulator-pose', (_event, pose?: { x?: number; y?: number; yaw?: number } | null) => setNav2SimulatorPose(pose)) ipcMain.handle('local-workspace:nav2:add-runtime-obstacle', (_event, obstacle?: Nav2RuntimeObstacle | null) => addNav2RuntimeObstacle(obstacle)) ipcMain.handle('local-workspace:nav2:clear-runtime-obstacles', () => clearNav2RuntimeObstacles()) ipcMain.handle('local-workspace:world-layout:read', async (_event, workspaceRoot?: string | null) => { const targetPath = worldLayoutPath(workspaceRoot) try { return normalizeCustomWorldLayout(JSON.parse(await readFile(targetPath, 'utf8'))) } catch (error) { const code = (error as NodeJS.ErrnoException).code if (code === 'ENOENT') return defaultWorldLayout() throw error } }) ipcMain.handle('local-workspace:world-layout:write', async (_event, workspaceRoot: string | null | undefined, layout: unknown) => { const targetPath = worldLayoutPath(workspaceRoot) const normalized = normalizeWorldLayout(layout) await mkdir(dirname(targetPath), { recursive: true }) await writeJsonAtomically(targetPath, normalized) return normalized }) ipcMain.handle('local-workspace:maps:read-artifacts', (_event, workspaceRoot: string | null | undefined, artifacts: SavedMapArtifactPath[]) => ( readWorkspaceSavedMapArtifacts(workspaceRoot, artifacts) )) ipcMain.handle('local-workspace:maps:sync', (_event, workspaceRoot: string | null | undefined, artifacts: SavedMapArtifactDownload[]) => ( syncWorkspaceSavedMapArtifacts(workspaceRoot, artifacts) )) ipcMain.handle('local-workspace:maps:generate-collision-layout', (_event, workspaceRoot: string | null | undefined, yamlPath: string, pgmPath: string) => ( generateWorkspaceMapCollisionLayout(workspaceRoot, yamlPath, pgmPath) )) ipcMain.handle('local-workspace:maps:generate-point-cloud-collision-layout', (_event, workspaceRoot: string | null | undefined, databasePath: string) => ( generateWorkspacePointCloudCollisionLayout(workspaceRoot, databasePath) )) ipcMain.handle('remote-workspace:modules:list', (_event, target: RemoteWorkspaceTarget, modules?: CourseModuleInput[] | null) => collectRemoteAvailableModules(target, modules)) ipcMain.handle('remote-workspace:ros-graph', (_event, target: RemoteWorkspaceTarget) => collectRemoteRosGraph(target)) ipcMain.handle('remote-workspace:maps:read-artifacts', (_event, target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactPath[]) => ( readRemoteSavedMapArtifacts(target, artifacts) )) ipcMain.handle('remote-workspace:maps:copy-active-rtabmap-database', (_event, target: RemoteWorkspaceTarget, localWorkspaceRoot: string | null | undefined, destinationPath: string) => ( copyRemoteActiveRtabmapDatabaseToLocal(target, localWorkspaceRoot, destinationPath) )) ipcMain.handle('remote-workspace:maps:upload-rtabmap-database', (_event, target: RemoteWorkspaceTarget, uploadUrl: string, databasePath?: string | null) => ( uploadRemoteRtabmapDatabase(target, uploadUrl, databasePath) )) ipcMain.handle('remote-workspace:maps:sync', (_event, target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactDownload[]) => ( syncRemoteSavedMapArtifacts(target, artifacts) )) ipcMain.handle('remote-workspace:modules:install', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => installRemoteWorkspaceModule(target, moduleName, folderName, templateRepositoryUrl, studentRepositoryUrl, options)) ipcMain.handle('remote-workspace:modules:create-custom', (_event, target: RemoteWorkspaceTarget, input?: CustomModuleScaffoldInput | null) => { if (!input) throw new Error('커스텀 모듈 정보가 없습니다.') return createRemoteCustomWorkspaceModule(target, input) }) ipcMain.handle('remote-workspace:modules:transfer', (_event, target: RemoteWorkspaceTarget, sourceWorkspaceRoot?: string | null, moduleName?: string | null, folderName?: string | null) => { if (!moduleName?.trim()) throw new Error('전송할 모듈명이 없습니다.') return transferLocalWorkspaceModuleToRemote(target, sourceWorkspaceRoot, moduleName, folderName) }) ipcMain.handle('remote-workspace:modules:start', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => startRemoteWorkspaceModule(target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork)) ipcMain.handle('remote-workspace:modules:build', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null) => buildRemoteWorkspaceModule(target, moduleName, folderName, gpuEnabled, rosDomainId, runtimeImages)) ipcMain.handle('remote-workspace:modules:stop', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null) => stopRemoteWorkspaceModule(target, moduleName, folderName)) ipcMain.handle('remote-workspace:modules:stop-many', (_event, target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => stopRemoteWorkspaceModules(target, modules)) ipcMain.handle('remote-workspace:modules:reset-session', (_event, target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null; templateRepositoryUrl?: string | null }>) => resetRemoteWorkspaceSession(target, modules)) ipcMain.handle('remote-workspace:ros:reset-session', (_event, target: RemoteWorkspaceTarget) => resetRemoteRosSession(target)) ipcMain.handle('remote-workspace:modules:restart', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => restartRemoteWorkspaceModule(target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork)) ipcMain.handle('remote-workspace:modules:logs', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null) => readRemoteWorkspaceModuleLogs(target, moduleName, folderName)) ipcMain.handle('remote-workspace:visualization:status', (_event, target: RemoteWorkspaceTarget) => readRemoteVisualizationBridgeStatus(target)) ipcMain.handle('remote-workspace:visualization:start', (_event, target: RemoteWorkspaceTarget, stack?: string | null, rosDomainId?: string | null, environmentProfile?: EnvironmentProfileConfig | null, rosNetwork?: RosNetworkConfig | null) => startRemoteVisualizationBridge(target, stack, rosDomainId, environmentProfile, rosNetwork)) ipcMain.handle('remote-workspace:visualization:stop', (_event, target: RemoteWorkspaceTarget) => stopRemoteVisualizationBridge(target)) ipcMain.handle('remote-workspace:git:status', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, selectedFilePath?: string | null) => readRemoteWorkspaceGitStatus(target, moduleName, folderName, selectedFilePath)) ipcMain.handle('remote-workspace:git:submit', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, message?: string | null, authorName?: string | null, authorEmail?: string | null, submissionId?: string | null, repositoryUrl?: string | null) => submitRemoteWorkspaceGitChanges(target, moduleName, folderName, message, authorName, authorEmail, submissionId, repositoryUrl)) ipcMain.handle('remote-workspace:git:team-publish', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, message?: string | null, authorName?: string | null, authorEmail?: string | null) => publishRemoteWorkspaceTeamGitChanges(target, moduleName, folderName, message, authorName, authorEmail)) ipcMain.handle('remote-workspace:git:team-pull', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null) => pullRemoteWorkspaceTeamGitChanges(target, moduleName, folderName)) ipcMain.handle('remote-workspace:git:restore-file', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, filePath?: string | null, status?: GitChangedFile['status'] | null) => restoreRemoteWorkspaceGitFile(target, moduleName, folderName, filePath, status)) ipcMain.handle('remote-workspace:git:submissions', (_event, target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => readRemoteWorkspaceGitSubmissions(target, modules)) ipcMain.handle('remote-workspace:git:commit-diff', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, commitHash?: string | null) => readRemoteWorkspaceGitCommitDiff(target, moduleName, folderName, commitHash)) ipcMain.handle('remote-workspace:module-files:read', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null) => collectRemoteModuleFiles(target, moduleName, folderName)) ipcMain.handle('remote-workspace:module-files:image', (_event, target: RemoteWorkspaceTarget, moduleName: string, rendererPath: string, folderName?: string | null) => readRemoteWorkspaceModuleImage(target, moduleName, rendererPath, folderName)) ipcMain.handle('remote-workspace:module-files:create', async (_event, target: RemoteWorkspaceTarget, moduleName: string, rendererPath: string, kind: 'file' | 'folder', folderName?: string | null) => { const resolved = await resolveRemoteRendererModulePath(target, moduleName, rendererPath, folderName) const command = kind === 'folder' ? `mkdir -p ${quoteShellSingle(resolved.absolutePath)}` : `mkdir -p ${quoteShellSingle(resolved.absolutePath.split('/').slice(0, -1).join('/'))} && : > ${quoteShellSingle(resolved.absolutePath)}` await runSsh(resolved.target, command, 10000) return { ok: true } }) ipcMain.handle('remote-workspace:module-files:write', async (_event, target: RemoteWorkspaceTarget, moduleName: string, rendererPath: string, content: string, folderName?: string | null) => { const resolved = await resolveRemoteRendererModulePath(target, moduleName, rendererPath, folderName) await runSsh(resolved.target, `printf %s ${quoteShellSingle(encodeBase64(content))} | base64 -d > ${quoteShellSingle(resolved.absolutePath)}`, 15000) return { ok: true } }) ipcMain.handle('remote-workspace:module-files:copy', async (_event, target: RemoteWorkspaceTarget, moduleName: string, fromRendererPath: string, toRendererPath: string, mode: 'copy' | 'cut', folderName?: string | null) => { const source = await resolveRemoteRendererModulePath(target, moduleName, fromRendererPath, folderName) const destination = await resolveRemoteRendererModulePath(target, moduleName, toRendererPath, folderName) const command = `${mode === 'cut' ? 'mv' : 'cp -a'} ${quoteShellSingle(source.absolutePath)} ${quoteShellSingle(destination.absolutePath)}` await runSsh(source.target, command, 20000) return { ok: true } }) ipcMain.handle('remote-workspace:module-files:rename', async (_event, target: RemoteWorkspaceTarget, moduleName: string, fromRendererPath: string, toRendererPath: string, folderName?: string | null) => { const source = await resolveRemoteRendererModulePath(target, moduleName, fromRendererPath, folderName) const destination = await resolveRemoteRendererModulePath(target, moduleName, toRendererPath, folderName) await runSsh(source.target, `mv ${quoteShellSingle(source.absolutePath)} ${quoteShellSingle(destination.absolutePath)}`, 10000) return { ok: true } }) ipcMain.handle('remote-workspace:module-files:delete', async (_event, target: RemoteWorkspaceTarget, moduleName: string, rendererPath: string, folderName?: string | null) => { const resolved = await resolveRemoteRendererModulePath(target, moduleName, rendererPath, folderName) await runSsh(resolved.target, `rm -rf ${quoteShellSingle(resolved.absolutePath)}`, 15000) return { ok: true } }) ipcMain.handle('local-workspace:visualization:status', () => readVisualizationBridgeStatus()) ipcMain.handle('local-workspace:visualization:start', (_event, stack?: string | null, workspaceRoot?: string | null, rosDomainId?: string | null, environmentProfile?: EnvironmentProfileConfig | null, rosNetwork?: RosNetworkConfig | null, savedMapDatabasePath?: string | null, savedMapWorldLayoutPath?: string | null) => startVisualizationBridge(stack, workspaceRoot, rosDomainId, environmentProfile, rosNetwork, savedMapDatabasePath, savedMapWorldLayoutPath)) ipcMain.handle('local-workspace:visualization:stop', (_event, environmentProfile?: EnvironmentProfileConfig | null) => stopVisualizationBridge(environmentProfile)) ipcMain.handle('local-workspace:git:status', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, selectedFilePath?: string | null) => readWorkspaceGitStatus(moduleName, workspaceRoot, folderName, selectedFilePath)) ipcMain.handle('local-workspace:git:submit', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, message?: string | null, authorName?: string | null, authorEmail?: string | null, submissionId?: string | null, repositoryUrl?: string | null) => submitWorkspaceGitChanges(moduleName, workspaceRoot, folderName, message, authorName, authorEmail, submissionId, repositoryUrl)) ipcMain.handle('local-workspace:git:team-publish', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, message?: string | null, authorName?: string | null, authorEmail?: string | null) => publishWorkspaceTeamGitChanges(moduleName, workspaceRoot, folderName, message, authorName, authorEmail)) ipcMain.handle('local-workspace:git:team-pull', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => pullWorkspaceTeamGitChanges(moduleName, workspaceRoot, folderName)) ipcMain.handle('local-workspace:git:restore-file', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, filePath?: string | null, status?: GitChangedFile['status'] | null) => restoreWorkspaceGitFile(moduleName, workspaceRoot, folderName, filePath, status)) ipcMain.handle('local-workspace:git:submissions', (_event, modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string | null) => readWorkspaceGitSubmissions(modules, workspaceRoot)) ipcMain.handle('local-workspace:git:commit-diff', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, commitHash?: string | null) => readWorkspaceGitCommitDiff(moduleName, workspaceRoot, folderName, commitHash)) ipcMain.handle('local-workspace:module-files:read', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => collectModuleFiles(moduleName, workspaceRoot, folderName)) ipcMain.handle('local-workspace:module-files:image', (_event, moduleName: string, rendererPath: string, workspaceRoot?: string | null, folderName?: string | null) => readWorkspaceModuleImage(moduleName, rendererPath, workspaceRoot, folderName)) ipcMain.handle('local-workspace:module-files:create', async (_event, moduleName: string, rendererPath: string, kind: 'file' | 'folder', workspaceRoot?: string | null, folderName?: string | null) => { const targetPath = await resolveRendererModulePath(moduleName, rendererPath, workspaceRoot, folderName) if (kind === 'folder') { await mkdir(targetPath, { recursive: true }) return { ok: true } } await mkdir(dirname(targetPath), { recursive: true }) await writeFile(targetPath, '', { flag: 'wx' }).catch((error: NodeJS.ErrnoException) => { if (error.code !== 'EEXIST') throw error }) return { ok: true } }) ipcMain.handle('local-workspace:module-files:write', async (_event, moduleName: string, rendererPath: string, content: string, workspaceRoot?: string | null, folderName?: string | null) => { const targetPath = await resolveRendererModulePath(moduleName, rendererPath, workspaceRoot, folderName) await mkdir(dirname(targetPath), { recursive: true }) await writeFile(targetPath, content, 'utf8') return { ok: true } }) ipcMain.handle('local-workspace:module-files:copy', async (_event, moduleName: string, fromRendererPath: string, toRendererPath: string, mode: 'copy' | 'cut', workspaceRoot?: string | null, folderName?: string | null) => { const fromPath = await resolveRendererModulePath(moduleName, fromRendererPath, workspaceRoot, folderName) const toPath = await resolveRendererModulePath(moduleName, toRendererPath, workspaceRoot, folderName) await mkdir(dirname(toPath), { recursive: true }) await cp(fromPath, toPath, { recursive: true, force: false, errorOnExist: true }) if (mode === 'cut') await rm(fromPath, { recursive: true, force: true }) return { ok: true } }) ipcMain.handle('local-workspace:module-files:rename', async (_event, moduleName: string, fromRendererPath: string, toRendererPath: string, workspaceRoot?: string | null, folderName?: string | null) => { const fromPath = await resolveRendererModulePath(moduleName, fromRendererPath, workspaceRoot, folderName) const toPath = await resolveRendererModulePath(moduleName, toRendererPath, workspaceRoot, folderName) await mkdir(dirname(toPath), { recursive: true }) await rename(fromPath, toPath) return { ok: true } }) ipcMain.handle('local-workspace:module-files:delete', async (_event, moduleName: string, rendererPath: string, workspaceRoot?: string | null, folderName?: string | null) => { const targetPath = await resolveRendererModulePath(moduleName, rendererPath, workspaceRoot, folderName) await deleteLocalWorkspacePath(targetPath, workspaceRoot) return { ok: true } }) } function getYoutubeVideoIdFromEmbedUrl(url: string): string | null { try { const parsed = new URL(url) const host = parsed.hostname.replace(/^www\./, '') const segments = parsed.pathname.split('/').filter(Boolean) const allowed = parsed.protocol === 'https:' && host === 'youtube.com' && segments[0] === 'embed' && /^[A-Za-z0-9_-]{6,}$/.test(segments[1] ?? '') return allowed ? segments[1] : null } catch { return null } } function openLearningVideoWindow(owner: BrowserWindow | null, input: LearningVideoInput): { ok: boolean; message?: string } { const url = input.url?.trim() ?? '' const videoId = getYoutubeVideoIdFromEmbedUrl(url) if (!videoId) return { ok: false, message: '지원하지 않는 동영상 URL입니다.' } const embedUrl = `https://www.youtube.com/embed/${videoId}?autoplay=1&rel=0` const videoWindow = new BrowserWindow({ width: 1080, height: 680, minWidth: 720, minHeight: 480, title: input.title?.trim() || '동영상강의', icon: getAppIconPath(), parent: owner ?? undefined, modal: false, autoHideMenuBar: true, backgroundColor: '#0a0b0e', webPreferences: { sandbox: true, nodeIntegration: false, contextIsolation: true, backgroundThrottling: false } }) videoWindow.webContents.setWindowOpenHandler(() => ({ action: 'deny' })) videoWindow.loadURL(embedUrl, { httpReferrer: 'https://workspace.autonomystudio.net/' }).catch((error) => { console.error('[learning-video] load failed', error) }) return { ok: true } } app.whenReady().then(() => { registerDevToolsShortcuts() registerTerminalHandlers() registerLocalWorkspaceHandlers() ipcMain.on('titlebar-minimize', (event) => BrowserWindow.fromWebContents(event.sender)?.minimize()) ipcMain.on('titlebar-maximize', (event) => { const target = BrowserWindow.fromWebContents(event.sender) if (!target) return target.isMaximized() ? target.unmaximize() : target.maximize() }) ipcMain.handle('titlebar-open-devtools', (event) => { const target = BrowserWindow.fromWebContents(event.sender) if (!target || target.isDestroyed()) return { opened: false, message: 'LMS 창을 찾지 못했습니다.' } target.show() target.focus() target.webContents.openDevTools({ mode: 'detach', activate: true }) setImmediate(() => target.webContents.devToolsWebContents?.focus()) return { opened: true } }) ipcMain.handle('app-diagnostics:read', () => readAppDiagnostics()) ipcMain.on('titlebar-close', (event) => BrowserWindow.fromWebContents(event.sender)?.close()) ipcMain.on('app-quit', () => app.quit()) ipcMain.handle('learning-video:open', (event, input: LearningVideoInput) => openLearningVideoWindow(BrowserWindow.fromWebContents(event.sender), input)) ipcMain.handle('modules:list', (_event, workspaceRoot?: string | null, courseModules?: CourseModuleInput[] | null) => collectAvailableModules(workspaceRoot, courseModules)) ipcMain.handle('modules:install', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => installModuleFromCache(moduleName, workspaceRoot, folderName, templateRepositoryUrl, studentRepositoryUrl, options)) ipcMain.handle('modules:delete', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => deleteWorkspaceModule(moduleName, workspaceRoot, folderName)) ipcMain.handle('modules:start', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => startWorkspaceModule(moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork)) ipcMain.handle('modules:build', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null, rosParams?: string | null) => buildWorkspaceModule(moduleName, workspaceRoot, folderName, gpuEnabled, rosDomainId, runtimeImages, rosParams)) ipcMain.handle('modules:stop', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => stopWorkspaceModule(moduleName, workspaceRoot, folderName)) ipcMain.handle('modules:stop-many', (_event, modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string | null) => stopWorkspaceModules(modules, workspaceRoot)) ipcMain.handle('modules:stop-running', () => stopRunningWorkspaceContainers()) ipcMain.handle('modules:restart', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => restartWorkspaceModule(moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork)) ipcMain.handle('modules:logs', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => readWorkspaceModuleLogs(moduleName, workspaceRoot, folderName)) ipcMain.handle('modules:update-parameters', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string) => updateWorkspaceModuleParameters(moduleName, workspaceRoot, folderName, rosParams)) ipcMain.handle('visualization:status', () => readVisualizationBridgeStatus()) ipcMain.handle('visualization:start', (_event, stack?: string | null, workspaceRoot?: string | null, rosDomainId?: string | null, environmentProfile?: EnvironmentProfileConfig | null, rosNetwork?: RosNetworkConfig | null) => startVisualizationBridge(stack, workspaceRoot, rosDomainId, environmentProfile, rosNetwork)) ipcMain.handle('visualization:stop', (_event, environmentProfile?: EnvironmentProfileConfig | null) => stopVisualizationBridge(environmentProfile)) ipcMain.handle('git:status', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, selectedFilePath?: string | null) => readWorkspaceGitStatus(moduleName, workspaceRoot, folderName, selectedFilePath)) ipcMain.handle('git:submit', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, message?: string | null, authorName?: string | null, authorEmail?: string | null, submissionId?: string | null, repositoryUrl?: string | null) => submitWorkspaceGitChanges(moduleName, workspaceRoot, folderName, message, authorName, authorEmail, submissionId, repositoryUrl)) ipcMain.handle('git:team-publish', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, message?: string | null, authorName?: string | null, authorEmail?: string | null) => publishWorkspaceTeamGitChanges(moduleName, workspaceRoot, folderName, message, authorName, authorEmail)) ipcMain.handle('git:team-pull', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => pullWorkspaceTeamGitChanges(moduleName, workspaceRoot, folderName)) ipcMain.handle('git:submissions', (_event, modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string | null) => readWorkspaceGitSubmissions(modules, workspaceRoot)) ipcMain.handle('git:commit-diff', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, commitHash?: string | null) => readWorkspaceGitCommitDiff(moduleName, workspaceRoot, folderName, commitHash)) ipcMain.handle('module-files:read', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => collectModuleFiles(moduleName, workspaceRoot, folderName)) ipcMain.handle('module-files:image', (_event, moduleName: string, rendererPath: string, workspaceRoot?: string | null, folderName?: string | null) => readWorkspaceModuleImage(moduleName, rendererPath, workspaceRoot, folderName)) ipcMain.handle('example-code:read', () => collectRootFiles(exampleCodeRoot, 'examples/example-code')) ipcMain.handle('module-files:create', async (_event, moduleName: string, rendererPath: string, kind: 'file' | 'folder', workspaceRoot?: string | null, folderName?: string | null) => { const targetPath = await resolveRendererModulePath(moduleName, rendererPath, workspaceRoot, folderName) if (kind === 'folder') { await mkdir(targetPath, { recursive: true }) return { ok: true } } await mkdir(dirname(targetPath), { recursive: true }) await writeFile(targetPath, '', { flag: 'wx' }).catch((error: NodeJS.ErrnoException) => { if (error.code !== 'EEXIST') throw error }) return { ok: true } }) ipcMain.handle('module-files:write', async (_event, moduleName: string, rendererPath: string, content: string, workspaceRoot?: string | null, folderName?: string | null) => { const targetPath = await resolveRendererModulePath(moduleName, rendererPath, workspaceRoot, folderName) await mkdir(dirname(targetPath), { recursive: true }) await writeFile(targetPath, content, 'utf8') return { ok: true } }) ipcMain.handle('module-files:copy', async (_event, moduleName: string, fromRendererPath: string, toRendererPath: string, mode: 'copy' | 'cut', workspaceRoot?: string | null, folderName?: string | null) => { const fromPath = await resolveRendererModulePath(moduleName, fromRendererPath, workspaceRoot, folderName) const toPath = await resolveRendererModulePath(moduleName, toRendererPath, workspaceRoot, folderName) await mkdir(dirname(toPath), { recursive: true }) if (mode === 'cut') { await rename(fromPath, toPath) } else { await cp(fromPath, toPath, { recursive: true, errorOnExist: true, force: false }) } return { ok: true } }) ipcMain.handle('module-files:rename', async (_event, moduleName: string, fromRendererPath: string, toRendererPath: string, workspaceRoot?: string | null, folderName?: string | null) => { const fromPath = await resolveRendererModulePath(moduleName, fromRendererPath, workspaceRoot, folderName) const toPath = await resolveRendererModulePath(moduleName, toRendererPath, workspaceRoot, folderName) await mkdir(dirname(toPath), { recursive: true }) await rename(fromPath, toPath) return { ok: true } }) ipcMain.handle('module-files:delete', async (_event, moduleName: string, rendererPath: string, workspaceRoot?: string | null, folderName?: string | null) => { const targetPath = await resolveRendererModulePath(moduleName, rendererPath, workspaceRoot, folderName) await rm(targetPath, { recursive: true, force: true }) return { ok: true } }) createSplashWindow() createWindow() }) app.on('window-all-closed', () => app.quit()) app.on('will-quit', () => { globalShortcut.unregisterAll() terminalStopTimers.forEach((timer) => clearTimeout(timer)) terminalStopTimers.clear() terminalProcesses.forEach((_proc, terminalKey) => intentionalTerminalStops.add(terminalKey)) terminalProcesses.forEach((proc) => proc.kill()) terminalProcesses.clear() intentionalTerminalStops.clear() })