7157 lines
330 KiB
TypeScript
7157 lines
330 KiB
TypeScript
import { app, BrowserWindow, dialog, Menu, globalShortcut, ipcMain, screen, shell } from 'electron'
|
|
import { appLog } from './appLogging'
|
|
import { checkDockerStatus } from './dockerStatus'
|
|
import { performChatClipboardAction, readChatClipboardState, showChatTextMenu } from './chatTextMenu'
|
|
import { cameraRuntimeArtifacts } from './cameraRuntime'
|
|
import { remoteModuleFilesScript } from './remoteModuleFiles'
|
|
import { applyAgentFiles, runAgentFileTool } from './agentFileTools'
|
|
import { agentRemoteFilesScript } from './agentRemoteFiles'
|
|
import { inspectAgentRuntime } from './agentRuntimeTools'
|
|
import { validateAgentTool, type AgentCheckedChange, type AgentToolRequest, type AgentToolResult } from '../shared/agentTools'
|
|
import { resolveContainerTerminal } from './containerTerminal'
|
|
import { remoteShellCommand, remoteShellEnvironment } from './remoteShell'
|
|
import { listLocalGitModules, localGitModulePath, remoteBranches, repositoryAddress } from './gitModuleImport'
|
|
import type { GitModuleSource, GitModuleImportInput } from '../shared/gitModuleImport'
|
|
import { yahboomDevicePreflight, yahboomModuleCases, yahboomRuntimeImage, yahboomRuntimeArtifacts, yahboomSimulation } from './yahboomRuntime'
|
|
import { visualizationProgress, reportVisualizationProgress } from './visualizationProgress'
|
|
import { moduleProgress, reportModuleProgress, createModuleProgressParser } from './moduleProgress'
|
|
import { bridgeArtifact, jetsonBridgeImage, localBridgeImage } from './bridgeArtifacts'
|
|
import type { WebContents } from 'electron'
|
|
import { spawn } from 'child_process'
|
|
import * as pty from 'node-pty'
|
|
import type { IPty } from 'node-pty'
|
|
import { createReadStream, createWriteStream, existsSync } 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'
|
|
import { pipeline } from 'node:stream/promises'
|
|
|
|
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<string, IPty>()
|
|
const terminalStopTimers = new Map<string, NodeJS.Timeout>()
|
|
const intentionalTerminalStops = new Set<string>()
|
|
const nav2RuntimeObstacleNames = new Set<string>()
|
|
const remoteSshQueues = new Map<string, Promise<void>>()
|
|
const remoteDockerStatsRetryAfter = new Map<string, number>()
|
|
const remoteGitModuleRoots = new Map<string, string>()
|
|
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 = localBridgeImage
|
|
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 {
|
|
appLog.write(kind, message)
|
|
}
|
|
|
|
async function readAppDiagnostics(): Promise<{ path: string; content: string }> {
|
|
const logPath = appLog.path()
|
|
if (!logPath) return { path: '', content: '' }
|
|
|
|
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|sdf|world|config|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<string, string> = {
|
|
'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 = {
|
|
filePaths?: string[]
|
|
root: string | null
|
|
absoluteRoot: string | null
|
|
files: Record<string, string>
|
|
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
|
|
allowExternal?: boolean
|
|
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<WorldLayoutObject>
|
|
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<void> {
|
|
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<string, MapCollisionRectangle>()
|
|
for (let row = 0; row < rows; row += 1) {
|
|
const nextRuns = new Map<string, MapCollisionRectangle>()
|
|
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<WorldLayout> {
|
|
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<string, PointCloudVoxel>()
|
|
let voxelSize = pointCloudVoxelSizes[pointCloudVoxelSizes.length - 1]
|
|
for (const candidateSize of pointCloudVoxelSizes) {
|
|
const nextVoxels = new Map<string, PointCloudVoxel>()
|
|
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<void> {
|
|
const exists = await runCommand('docker', ['image', 'inspect', slamRosBridgeImage], undefined, undefined, 10_000)
|
|
.then(() => true)
|
|
.catch(() => false)
|
|
if (!exists) await ensureBridgeImage(slamRosBridgeImage)
|
|
}
|
|
|
|
async function exportWorkspaceMapPointCloud(workspaceRoot: string, databaseFileName: string): Promise<Buffer> {
|
|
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<WorldLayout> {
|
|
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<PointCloudLayoutCache>
|
|
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<Array<{ name: SavedMapArtifactName; fileName: string; base64: string }>> {
|
|
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<string, string>
|
|
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<string, string>
|
|
}
|
|
}
|
|
|
|
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<string, string> {
|
|
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<CustomModuleImportSource> {
|
|
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(/<name>\s*([^<]+?)\s*<\/name>/)?.[1]?.trim() ?? ''
|
|
if (!/^[a-z][a-z0-9_]*$/.test(packageName)) {
|
|
throw new Error('package.xml의 패키지명이 ROS2 패키지명 형식과 맞지 않습니다.')
|
|
}
|
|
|
|
const buildType = /<buildtool_depend>\s*ament_python\s*<\/buildtool_depend>/.test(packageXml)
|
|
? 'ament_python'
|
|
: /<buildtool_depend>\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<CustomModuleImportSource | null> {
|
|
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<string, string> {
|
|
const module = validateCustomModuleScaffold(input)
|
|
const executable = `${module.packageName}_node`
|
|
const nodePath = `${module.packageName}/node.py`
|
|
const packageFiles = {
|
|
'package.xml': [
|
|
'<?xml version="1.0"?>',
|
|
'<package format="3">',
|
|
` <name>${module.packageName}</name>`,
|
|
' <version>0.0.1</version>',
|
|
` <description>${module.name}</description>`,
|
|
' <maintainer email="student@autonomy-studio.local">Autonomy Studio Student</maintainer>',
|
|
' <license>Apache-2.0</license>',
|
|
' <buildtool_depend>ament_python</buildtool_depend>',
|
|
' <exec_depend>rclpy</exec_depend>',
|
|
' <exec_depend>launch</exec_depend>',
|
|
' <exec_depend>launch_ros</exec_depend>',
|
|
' <export><build_type>ament_python</build_type></export>',
|
|
'</package>',
|
|
'',
|
|
].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 inspectLocalGitModule(workspaceRoot: string, folder: string): Promise<GitModuleSource> {
|
|
const workspace = expandUserPath(workspaceRoot)
|
|
if (!workspace) throw new Error('워크스페이스 경로가 설정되어 있지 않습니다.')
|
|
const root = await localGitModulePath(workspace, folder)
|
|
const git = (args: string[]) => runCommandOutput('git', args, root, { ...process.env, GIT_TERMINAL_PROMPT: '0' }, 15_000, 'stdout')
|
|
const top = (await git(['rev-parse', '--show-toplevel'])).trim()
|
|
if (resolve(top) !== root) throw new Error('모듈 자체의 Git 저장소가 필요합니다.')
|
|
const remote = await git(['remote', 'get-url', 'student']).catch(() => git(['remote', 'get-url', 'origin']))
|
|
const repository = repositoryAddress(remote.trim())
|
|
const currentBranch = (await git(['branch', '--show-current'])).trim()
|
|
const dirty = Boolean((await git(['status', '--porcelain'])).trim())
|
|
const head = (await git(['rev-parse', 'HEAD'])).trim()
|
|
let branches: GitModuleSource['branches']
|
|
try {
|
|
branches = remoteBranches(await git(['ls-remote', '--heads', withGitCredential(repository)]))
|
|
} catch { throw new Error('Git 저장소를 조회하지 못했습니다. 접근 권한과 네트워크를 확인하세요.') }
|
|
return { repository, currentBranch, branches, dirty, pushed: branches.some(branch => branch.name === currentBranch && branch.commit === head) }
|
|
}
|
|
|
|
async function importRemoteGitModule(targetInput: RemoteWorkspaceTarget, workspaceRoot: string, input: GitModuleImportInput & { folderName: string }): Promise<{ ok: boolean; workspacePath: string }> {
|
|
const target = normalizeRemoteTarget(targetInput)
|
|
const module = validateCustomModuleScaffold(input)
|
|
const source = await inspectLocalGitModule(workspaceRoot, input.sourceFolder)
|
|
if (source.dirty || !source.pushed) throw new Error('로컬 변경 사항을 커밋하고 푸시한 뒤 다시 시도하세요.')
|
|
const branch = source.branches.find(branch => branch.name === input.branch && branch.commit === input.commit)
|
|
if (!branch) throw new Error('브랜치가 변경되었습니다. 저장소 정보를 새로고침하세요.')
|
|
const repository = remoteRepositoryUrl(source.repository)
|
|
const authenticated = withGitCredential(repository)
|
|
const url = new URL(authenticated)
|
|
const authorization = ['http:', 'https:'].includes(url.protocol) && url.username
|
|
? `Authorization: Basic ${Buffer.from(`${decodeURIComponent(url.username)}:${decodeURIComponent(url.password)}`).toString('base64')}` : ''
|
|
const config = encodeBase64(customModuleConfigContent(module))
|
|
// Stage the clone and publish only after validation. Never overwrite an existing workspace.
|
|
const script = [
|
|
'set -e',
|
|
`ROOT=${quoteShellSingle(target.workspaceRoot)}`,
|
|
...expandRemotePathVar('ROOT'),
|
|
'mkdir -p "$ROOT"',
|
|
`TARGET="$ROOT/"${quoteShellSingle(module.folderName)}`,
|
|
'if [ -e "$TARGET" ] || [ -L "$TARGET" ]; then echo "Module folder already exists" >&2; exit 1; fi',
|
|
'STAGING=$(mktemp -d "$ROOT/.git-import-XXXXXXXX")',
|
|
'trap \'rm -rf -- "$STAGING"\' EXIT',
|
|
`AUTH=$(printf %s ${quoteShellSingle(encodeBase64(authorization))} | base64 -d)`,
|
|
'export GIT_TERMINAL_PROMPT=0',
|
|
`if ! GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.extraHeader GIT_CONFIG_VALUE_0="$AUTH" git clone --single-branch --branch ${quoteShellSingle(branch.name)} -- ${quoteShellSingle(repository)} "$STAGING/module" 2>"$STAGING/clone-error"; then echo "Git clone failed: check repository access from the platform" >&2; exit 1; fi`,
|
|
'unset AUTH',
|
|
`test "$(git -C "$STAGING/module" rev-parse HEAD)" = ${quoteShellSingle(branch.commit)} || { echo "Branch changed; refresh and retry" >&2; exit 1; }`,
|
|
`python3 - "$STAGING/module" ${quoteShellSingle(config)} <<'PY'`,
|
|
'import base64, json, os, subprocess, sys',
|
|
'root = sys.argv[1]',
|
|
'config = json.loads(base64.b64decode(sys.argv[2]))',
|
|
'paths = subprocess.check_output(["git", "-C", root, "ls-tree", "-r", "--name-only", "HEAD"]).decode().splitlines()',
|
|
'if any(set(path.split("/")) & {"build", "install", "log", "__pycache__"} for path in paths): raise SystemExit("Repository contains build artifacts; remove them before importing")',
|
|
'for path in paths:',
|
|
' if os.path.islink(os.path.join(root, path)): raise SystemExit("Repository symlinks are not supported for module import")',
|
|
'package = config["packageName"]',
|
|
'candidates = [root, os.path.join(root, "src", package)]',
|
|
'if not any(os.path.isfile(os.path.join(p, "package.xml")) and os.path.isfile(os.path.join(p, "launch", config["launchFile"])) for p in candidates): raise SystemExit("Package or launch file is missing in the selected branch")',
|
|
'for script in ("build.sh", "run.sh"):',
|
|
' if not os.path.isfile(os.path.join(root, script)): raise SystemExit("Module build.sh/run.sh is missing")',
|
|
' os.chmod(os.path.join(root, script), 0o755)',
|
|
'with open(os.path.join(root, ".autonomy-studio-module.json"), "w") as fp: json.dump(config, fp, indent=2)',
|
|
'PY',
|
|
'mv -T -n "$STAGING/module" "$TARGET"',
|
|
'test ! -d "$STAGING/module" || { echo "Module folder already exists" >&2; exit 1; }',
|
|
'printf "%s" "$TARGET"',
|
|
].join('\n')
|
|
try {
|
|
const workspacePath = (await runSshOutput(target, script, 120_000)).trim()
|
|
return { ok: true, workspacePath }
|
|
} catch {
|
|
// SSH failures can include the command (and its temporary authorization header).
|
|
throw new Error('Git 모듈 복제에 실패했습니다. 원격 저장소 접근, 기존 폴더, 소스 및 빌드 산출물 포함 여부를 확인하세요.')
|
|
}
|
|
}
|
|
|
|
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<void>((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 {
|
|
const yahboomImage = yahboomRuntimeImage(folderName)
|
|
if (yahboomImage) return yahboomImage
|
|
// 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<string> {
|
|
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<string> {
|
|
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<string> {
|
|
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<string | null> {
|
|
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<string | null> {
|
|
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<string | null> {
|
|
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<string, { running: boolean; exists: boolean; status: string }>())
|
|
const dockerStats = await readDockerStats().catch(() => new Map<string, { cpu: number; memory: number }>())
|
|
const modules = await Promise.all(moduleInputs
|
|
.map(async (module): Promise<CachedModuleInfo> => {
|
|
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<string, { running: boolean; exists: boolean; status: string }>()
|
|
const dockerStats = new Map<string, { cpu: number; memory: number }>()
|
|
const installedModules = new Map<string, { path: string; parameters: string[] }>()
|
|
|
|
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
|
|
}
|
|
|
|
function handleModuleOperation(channel: string, handler: Parameters<typeof ipcMain.handle>[1]): void {
|
|
ipcMain.handle(channel, (event, ...args) => {
|
|
const remote = channel.startsWith('remote-')
|
|
const folderName = String(args[2] || args[remote ? 1 : 0])
|
|
const send = (message: string): void => {
|
|
if (!event.sender.isDestroyed()) event.sender.send('module:progress', folderName, message)
|
|
}
|
|
return moduleProgress.run(send, () => visualizationProgress.run((message) => {
|
|
if (!message.startsWith('시각화')) send(message)
|
|
}, async () => {
|
|
send('모듈 작업 준비 중...')
|
|
try { return await handler(event, ...args) } finally { send('') }
|
|
}))
|
|
})
|
|
}
|
|
|
|
async function runCommandOutput(command: string, args: string[], cwd?: string, env?: NodeJS.ProcessEnv, timeoutMs = 120_000, outputMode: 'combined' | 'stdout' = 'combined', input?: string): Promise<string> {
|
|
const readProgress = createModuleProgressParser()
|
|
return new Promise<string>((resolvePromise, rejectPromise) => {
|
|
let output = ''
|
|
let stdout = ''
|
|
let terminated = false
|
|
const child = spawn(command, args, {
|
|
cwd,
|
|
detached: process.platform !== 'win32',
|
|
stdio: [input === undefined ? 'ignore' : 'pipe', '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)
|
|
|
|
if (input !== undefined) {
|
|
child.stdin?.on('error', () => { /* Process exit is reported by close/error. */ })
|
|
child.stdin?.end(input)
|
|
}
|
|
child.stdout?.setEncoding('utf8')
|
|
child.stdout?.on('data', (data: string) => {
|
|
stdout += data.toString()
|
|
output += data.toString()
|
|
readProgress(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(outputMode === 'stdout' ? stdout : 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<void> {
|
|
await runCommandOutput(command, args, cwd, env, timeoutMs)
|
|
}
|
|
|
|
async function listWorkspaceDockerImages(target?: RemoteWorkspaceTarget): Promise<{ images: string[]; message?: string }> {
|
|
try {
|
|
const output = target
|
|
? await runSshOutput(target, "docker image ls --format '{{json .}}'", 10_000)
|
|
: 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 === '<none>' || tag === '<none>') 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<RosGraphSnapshot> : {}
|
|
const topics = Array.isArray(snapshot.topics) ? snapshot.topics.flatMap((topic): RosGraphTopic[] => {
|
|
if (!topic || typeof topic !== 'object') return []
|
|
const candidate = topic as Partial<RosGraphTopic>
|
|
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<RosGraphEndpoint>
|
|
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<RosGraphSnapshot> {
|
|
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<RosGraphSnapshot> {
|
|
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<void> {
|
|
if (redirects > 5) throw new Error('artifact redirect limit exceeded')
|
|
await mkdir(dirname(targetPath), { recursive: true })
|
|
|
|
await new Promise<void>((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)
|
|
void pipeline(response, file).then(resolvePromise, rejectPromise)
|
|
})
|
|
request.setTimeout(60000, () => request.destroy(new Error('artifact download timed out')))
|
|
request.on('error', rejectPromise)
|
|
})
|
|
}
|
|
|
|
async function artifactSha256(filePath: string): Promise<string> {
|
|
const hash = createHash('sha256')
|
|
for await (const chunk of createReadStream(filePath)) hash.update(chunk)
|
|
return hash.digest('hex')
|
|
}
|
|
|
|
async function ensureArtifactFile(url?: string | null, fileName?: string | null, checksumSha256?: string | null): Promise<string | null> {
|
|
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 })
|
|
reportVisualizationProgress('다운로드 중...')
|
|
await downloadArtifact(artifactUrl, targetPath)
|
|
if (expected) {
|
|
reportVisualizationProgress('다운로드 파일 확인 중...')
|
|
let actual = await artifactSha256(targetPath)
|
|
if (actual !== expected) {
|
|
// Artifact URLs are stable, so an existing filename can be an older cached release.
|
|
await rm(targetPath, { force: true })
|
|
reportVisualizationProgress('다운로드 중...')
|
|
await downloadArtifact(artifactUrl, targetPath)
|
|
reportVisualizationProgress('다운로드 파일 확인 중...')
|
|
actual = await artifactSha256(targetPath)
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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
|
|
}
|
|
reportVisualizationProgress('이미지 불러오는 중...')
|
|
if (filePath.toLowerCase().endsWith('.tar.zst') || filePath.toLowerCase().endsWith('.tzst')) {
|
|
const zstdPath = getBundledZstdPath() ?? 'zstd'
|
|
await runCommand('bash', ['-o', 'pipefail', '-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)
|
|
}
|
|
reportVisualizationProgress('시각화 시작 중...')
|
|
}
|
|
}
|
|
|
|
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<Pick<RemoteWorkspaceTarget, 'host' | 'user' | 'port'>> & { 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<T>(target: ReturnType<typeof normalizeRemoteTarget>, task: () => Promise<T>): Promise<T> {
|
|
const key = `${target.user}@${target.host}:${target.port}`
|
|
const previous = remoteSshQueues.get(key) ?? Promise.resolve()
|
|
let release: () => void = () => undefined
|
|
const current = new Promise<void>((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<string> {
|
|
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}`,
|
|
remoteShellCommand(command)
|
|
],
|
|
undefined,
|
|
remoteShellEnvironment(process.env),
|
|
timeoutMs,
|
|
'stdout'
|
|
))
|
|
}
|
|
|
|
async function runUnqueuedSshOutput(target: RemoteWorkspaceTarget, command: string, timeoutMs: number, input?: string): Promise<string> {
|
|
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}`,
|
|
remoteShellCommand(command),
|
|
],
|
|
undefined,
|
|
remoteShellEnvironment(process.env),
|
|
timeoutMs,
|
|
'stdout',
|
|
input,
|
|
)
|
|
}
|
|
|
|
async function readRemoteDockerStatsOutput(target: RemoteWorkspaceTarget): Promise<string> {
|
|
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<void> {
|
|
await runSshOutput(target, command, timeoutMs)
|
|
}
|
|
|
|
async function copyRemoteFileToLocal(
|
|
target: RemoteWorkspaceTarget,
|
|
remotePath: string,
|
|
localPath: string,
|
|
timeoutMs = 900_000,
|
|
): Promise<void> {
|
|
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<Array<{ name: SavedMapArtifactName; fileName: string; base64: string }>> {
|
|
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)}`,
|
|
// A missing directory is normal on first install, not a permission failure.
|
|
'mkdir -p "$MAPS_DIRECTORY" 2>/dev/null || true',
|
|
'if [ ! -d "$MAPS_DIRECTORY" ] || [ ! -w "$MAPS_DIRECTORY" ] || [ ! -x "$MAPS_DIRECTORY" ]; then',
|
|
' IMAGE=""',
|
|
' for CANDIDATE in future-nav2:humble-yahboom future-nav2:humble-yahboom-agent future-nav2:humble-jetson_orin future-nav2:humble; do',
|
|
' if docker image inspect "$CANDIDATE" >/dev/null 2>&1; then IMAGE="$CANDIDATE"; break; fi',
|
|
' done',
|
|
' 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"',
|
|
'test -w "$MAPS_DIRECTORY" && test -x "$MAPS_DIRECTORY" || { echo "저장된 맵 디렉터리 권한 복구에 실패했습니다: $MAPS_DIRECTORY" >&2; exit 1; }',
|
|
...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<VisualizationBridgeStatus> {
|
|
try {
|
|
const output = await runSshOutput(
|
|
target,
|
|
'docker ps --format "{{.Names}}\\t{{.Status}}" 2>/dev/null || true',
|
|
10_000,
|
|
)
|
|
const lines = output.split('\n')
|
|
const nav2 = lines.find((line) => line.startsWith('nav2-ros-bridge\t'))
|
|
const slam = lines.find((line) => line.startsWith('slam-ros-bridge\t'))
|
|
const autoware = lines.find((line) => line.startsWith('autoware-ros-bridge\t'))
|
|
if (nav2) return { ok: true, running: true, stack: 'nav2' }
|
|
if (slam) return { ok: true, running: true, stack: 'slam' }
|
|
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) }
|
|
}
|
|
}
|
|
|
|
const managedVisualizationBridgeEnvKeys = new Set([
|
|
'ROS_DOMAIN_ID',
|
|
'ROS_LOCALHOST_ONLY',
|
|
'FASTDDS_DEFAULT_PROFILES_FILE',
|
|
'FASTRTPS_DEFAULT_PROFILES_FILE',
|
|
'AUTONOMY_STUDIO_ROS_NETWORK',
|
|
])
|
|
|
|
function isManagedVisualizationBridgeEnvKey(key: string): boolean {
|
|
return managedVisualizationBridgeEnvKeys.has(key)
|
|
}
|
|
|
|
async function startRemoteVisualizationBridge(
|
|
target: RemoteWorkspaceTarget,
|
|
stackInput?: string | null,
|
|
rosDomainIdInput?: string | null,
|
|
environmentProfile?: EnvironmentProfileConfig | null,
|
|
rosNetwork?: RosNetworkConfig | null,
|
|
): Promise<VisualizationBridgeStatus> {
|
|
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) && !isManagedVisualizationBridgeEnvKey(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) {
|
|
image = remoteJetsonBridgeImage || jetsonBridgeImage
|
|
}
|
|
const imageArtifact = bridgeArtifact(image)
|
|
?? environmentProfile?.runtimeImages?.find((artifact) => artifact?.image?.trim() === image)
|
|
const remoteImageExists = imageArtifact?.url?.trim()
|
|
? (await runSshOutput(target, `docker image inspect ${quoteShellSingle(image)} >/dev/null 2>&1 && printf present || true`, 10000)).trim() === 'present'
|
|
: false
|
|
if (imageArtifact?.url?.trim() && !remoteImageExists) {
|
|
reportVisualizationProgress('다운로드 및 이미지 준비 중...')
|
|
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',
|
|
' PART=$(mktemp "$ARCHIVE.XXXXXX")',
|
|
' trap \'rm -f "$PART"\' EXIT',
|
|
' curl -fL --connect-timeout 15 --max-time 750 "$URL" -o "$PART"',
|
|
' mv "$PART" "$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) bash -o pipefail -c \'zstd -dc "$1" | docker load\' _ "$ARCHIVE" ;;',
|
|
' *) 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)
|
|
let bridgeContextRevision: string | null = null
|
|
if (stack !== 'autoware') {
|
|
const context = resolveResourceContextPath('resources/slam-ros-bridge')
|
|
if (!context) throw new Error('Bundled SLAM bridge source is missing')
|
|
const files = { 'bridge.py': await readFile(join(context, 'bridge.py'), 'utf8'), 'requirements.txt': await readFile(join(context, 'requirements.txt'), 'utf8') }
|
|
bridgeContextRevision = createHash('sha256').update(JSON.stringify(files)).digest('hex')
|
|
await runUnqueuedSshOutput(target, `python3 -c ${quoteShellSingle([
|
|
'import json, os, pathlib, sys',
|
|
'data = json.load(sys.stdin)',
|
|
'root = pathlib.Path.home() / ".autonomy-studio/runtime/bridges" / data["revision"]',
|
|
'root.mkdir(parents=True, exist_ok=True)',
|
|
'for name in ("bridge.py", "requirements.txt"):',
|
|
' temporary = root / ("." + name + "." + str(os.getpid()) + ".tmp")',
|
|
' temporary.write_text(data["files"][name], encoding="utf-8")',
|
|
' temporary.replace(root / name)',
|
|
].join('\n'))}`, 30_000, JSON.stringify({ revision: bridgeContextRevision, files }))
|
|
}
|
|
|
|
reportVisualizationProgress('시각화 시작 중...')
|
|
|
|
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"',
|
|
...(bridgeContextRevision ? [`-v "$HOME/.autonomy-studio/runtime/bridges/${bridgeContextRevision}:/app:ro"`] : []),
|
|
...(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<VisualizationBridgeStatus> {
|
|
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<void> {
|
|
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<StudioGitMetadata> {
|
|
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<void> {
|
|
await writeFile(resolve(moduleRoot, studioGitMetadataFile), `${JSON.stringify(metadata, null, 2)}\n`, 'utf8')
|
|
}
|
|
|
|
async function readStudioGitBaseCommit(moduleRoot: string): Promise<string | null> {
|
|
const metadata = await readStudioGitMetadata(moduleRoot)
|
|
return metadata.baseCommit?.trim() || null
|
|
}
|
|
|
|
async function writeStudioGitBaseCommit(moduleRoot: string, baseCommit: string): Promise<void> {
|
|
const metadata = await readStudioGitMetadata(moduleRoot)
|
|
await writeStudioGitMetadata(moduleRoot, { ...metadata, baseCommit })
|
|
}
|
|
|
|
async function appendStudioGitSubmission(moduleRoot: string, submission: StudioGitSubmission): Promise<void> {
|
|
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<string, StudioGitSubmission>()
|
|
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<StudioGitSubmission[]> {
|
|
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<string | null> {
|
|
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<string> {
|
|
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<typeof normalizeRemoteTarget>; 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<string> {
|
|
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<string> {
|
|
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<GitStatusInfo> {
|
|
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<GitStatusInfo> {
|
|
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<string, string>()
|
|
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<StudioGitMetadata> {
|
|
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<void> {
|
|
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<void> {
|
|
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<Map<string, { running: boolean; exists: boolean; status: string }>> {
|
|
const statuses = new Map<string, { running: boolean; exists: boolean; status: string }>()
|
|
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<Map<string, { cpu: number; memory: number }>> {
|
|
const stats = new Map<string, { cpu: number; memory: number }>()
|
|
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<string | null> {
|
|
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<string | null> {
|
|
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<string | null> {
|
|
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<string | null> {
|
|
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): Promise<void> {
|
|
const artifact = bridgeArtifact(image)
|
|
if (!artifact) throw new Error(`브리지 이미지의 다운로드 설정이 없습니다: ${image}`)
|
|
const architecture = process.arch === 'x64' ? 'amd64' : process.arch
|
|
if (architecture !== artifact.architecture) {
|
|
throw new Error(`브리지 이미지 아키텍처가 PC와 다릅니다: ${artifact.architecture} / ${architecture}`)
|
|
}
|
|
await prepareRuntimeImages([artifact])
|
|
await runCommand('docker', ['image', 'inspect', image], undefined, undefined, 10000)
|
|
}
|
|
|
|
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, string>): 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<string, string>): Promise<void> {
|
|
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<void> {
|
|
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<string, { running: boolean; exists: boolean; status: string }>())
|
|
|
|
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<VisualizationBridgeStatus> {
|
|
try {
|
|
const statuses = await readDockerStatuses().catch(() => new Map<string, { running: boolean; exists: boolean; status: string }>())
|
|
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<void> {
|
|
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<string, unknown> = {},
|
|
): Promise<VisualizationBridgeStatus> {
|
|
const agentUrl = profile.agent?.url?.trim().replace(/\/+$/, '')
|
|
if (!agentUrl) throw new Error('원격 제어 Agent URL이 설정되지 않았습니다.')
|
|
|
|
const headers: Record<string, string> = { '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<VisualizationBridgeStatus> {
|
|
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() ?? ''}`
|
|
: rosNetwork?.allowExternal === true ? 'lan' : '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<string, { running: boolean; exists: boolean; status: string }>())
|
|
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)
|
|
}
|
|
|
|
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) || rosNetwork?.allowExternal === true ? '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]) => {
|
|
if (!isManagedVisualizationBridgeEnvKey(key)) 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<string>()
|
|
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<VisualizationBridgeStatus> {
|
|
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<string, { running: boolean; exists: boolean; status: string }>())
|
|
const targetNames = new Set<string>((['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"',
|
|
// An existing workspace owns its source, commits and build artifacts. Install
|
|
// retries (including changed repository credentials) must not overwrite it.
|
|
'if [ -e "$TARGET/.git" ]; then',
|
|
' printf "OK\\t%s\\n" "$TARGET"',
|
|
' exit 0',
|
|
'fi',
|
|
'git clone "$TEMPLATE_REPO" "$TARGET"',
|
|
'if [ -n "$STUDENT_REPO" ]; then',
|
|
' git -C "$TARGET" remote add student "$STUDENT_REPO"',
|
|
' git -C "$TARGET" fetch student --prune',
|
|
' 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<string | null> {
|
|
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<void> {
|
|
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, lanAddresses: string[] = []): 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, ...lanAddresses]))
|
|
const peerAddresses = Array.from(new Set(['127.0.0.1', peerAddress]))
|
|
return [
|
|
'<?xml version="1.0" encoding="UTF-8" ?>',
|
|
'<profiles xmlns="http://www.eprosima.com/XMLSchemas/fastRTPS_Profiles">',
|
|
' <transport_descriptors>',
|
|
' <transport_descriptor>',
|
|
' <transport_id>autonomy_studio_udp</transport_id>',
|
|
' <type>UDPv4</type>',
|
|
' <sendBufferSize>1048576</sendBufferSize>',
|
|
' <receiveBufferSize>1048576</receiveBufferSize>',
|
|
' <maxMessageSize>1200</maxMessageSize>',
|
|
' <interfaceWhiteList>',
|
|
...localAddresses.map((address) => ` <address>${address}</address>`),
|
|
' </interfaceWhiteList>',
|
|
' </transport_descriptor>',
|
|
' </transport_descriptors>',
|
|
' <participant profile_name="autonomy_studio_unicast_peer" is_default_profile="true">',
|
|
' <rtps>',
|
|
' <useBuiltinTransports>false</useBuiltinTransports>',
|
|
' <userTransports>',
|
|
' <transport_id>autonomy_studio_udp</transport_id>',
|
|
' </userTransports>',
|
|
' <builtin>',
|
|
...(lanAddresses.length ? [
|
|
' <metatrafficMulticastLocatorList>',
|
|
` <locator><udpv4><address>239.255.0.1</address><port>${discoveryBasePort}</port></udpv4></locator>`,
|
|
' </metatrafficMulticastLocatorList>',
|
|
] : []),
|
|
' <metatrafficUnicastLocatorList>',
|
|
...localAddresses.flatMap((address) => [
|
|
' <locator>',
|
|
' <udpv4>',
|
|
` <address>${address}</address>`,
|
|
' <port>0</port>',
|
|
' </udpv4>',
|
|
' </locator>',
|
|
]),
|
|
' </metatrafficUnicastLocatorList>',
|
|
' <initialPeersList>',
|
|
...(lanAddresses.length ? [
|
|
` <locator><udpv4><address>239.255.0.1</address><port>${discoveryBasePort}</port></udpv4></locator>`,
|
|
] : []),
|
|
...peerAddresses.flatMap((address) => peers.flatMap((port) => [
|
|
' <locator>',
|
|
' <udpv4>',
|
|
` <address>${address}</address>`,
|
|
` <port>${port}</port>`,
|
|
' </udpv4>',
|
|
' </locator>',
|
|
])),
|
|
' </initialPeersList>',
|
|
' </builtin>',
|
|
' </rtps>',
|
|
' </participant>',
|
|
'</profiles>',
|
|
''
|
|
].join('\n')
|
|
}
|
|
|
|
function buildCycloneDdsConfig(): string {
|
|
return [
|
|
'<?xml version="1.0" encoding="UTF-8" ?>',
|
|
'<CycloneDDS xmlns="https://cdds.io/config" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="https://cdds.io/config https://raw.githubusercontent.com/eclipse-cyclonedds/cyclonedds/master/etc/cyclonedds.xsd">',
|
|
' <Domain Id="any">',
|
|
' <General>',
|
|
' <Interfaces>',
|
|
' <NetworkInterface autodetermine="true" priority="default" multicast="default" />',
|
|
' </Interfaces>',
|
|
' <AllowMulticast>default</AllowMulticast>',
|
|
' <MaxMessageSize>65500B</MaxMessageSize>',
|
|
' </General>',
|
|
' <Discovery>',
|
|
' <ParticipantIndex>auto</ParticipantIndex>',
|
|
' <MaxAutoParticipantIndex>120</MaxAutoParticipantIndex>',
|
|
' </Discovery>',
|
|
' <Internal>',
|
|
' <Watermarks>',
|
|
' <WhcHigh>500kB</WhcHigh>',
|
|
' </Watermarks>',
|
|
' </Internal>',
|
|
' </Domain>',
|
|
'</CycloneDDS>',
|
|
''
|
|
].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<string> {
|
|
const output = await runCommandOutput('tailscale', ['ip', '-4'], undefined, undefined, 10_000)
|
|
return resolveTailscaleIpv4(output)
|
|
}
|
|
|
|
async function readRemoteTailscaleIpv4(target: RemoteWorkspaceTarget): Promise<string> {
|
|
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<string | null> {
|
|
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
|
|
}
|
|
|
|
function parseCameraLanAddresses(output: string): string[] {
|
|
const interfaces = output.split('\n').filter((line) => line.trim()).flatMap((line) => JSON.parse(line))
|
|
return Array.from(new Set<string>(interfaces.flatMap((item) =>
|
|
(item.addr_info ?? [])
|
|
.filter((address: { family?: string; scope?: string; local?: string }) =>
|
|
address.family === 'inet' && address.scope === 'global'
|
|
&& typeof address.local === 'string'
|
|
&& /^(?:\d{1,3}\.){3}\d{1,3}$/.test(address.local)
|
|
&& address.local.split('.').every((part) => Number(part) <= 255))
|
|
.map((address: { local: string }) => address.local),
|
|
)))
|
|
}
|
|
|
|
function cameraLanEnabled(folderName: string): boolean {
|
|
return /^\[lms-nav2-yahboom-camera\]yahboom_camera_v[^/]+_ws$/.test(folderName)
|
|
}
|
|
|
|
async function prepareRemoteRosNetworkConfig(
|
|
target: RemoteWorkspaceTarget,
|
|
runtimeRoot: string,
|
|
network?: RosNetworkConfig | null,
|
|
rosDomainId?: string | null,
|
|
allowCameraLan = false,
|
|
): Promise<string | null> {
|
|
if (!rosNetworkEnabled(network) && !allowCameraLan) return null
|
|
|
|
const [peerAddress, localAddress] = await Promise.all([
|
|
rosNetworkEnabled(network) ? readLocalTailscaleIpv4() : Promise.resolve('127.0.0.1'),
|
|
rosNetworkEnabled(network) ? readRemoteTailscaleIpv4(target) : Promise.resolve('127.0.0.1'),
|
|
])
|
|
const lanAddresses = allowCameraLan ? parseCameraLanAddresses(await runSshOutput(target, [
|
|
'set -e',
|
|
'for interface in /sys/class/net/*; do',
|
|
' [ -e "$interface/device" ] || continue',
|
|
' ip -j -4 address show dev "${interface##*/}" up scope global',
|
|
'done',
|
|
].join('\n'), 15_000)) : []
|
|
if (allowCameraLan && !lanAddresses.length) throw new Error('카메라 내부망 통신에 사용할 활성 Ethernet/Wi-Fi IPv4 주소가 없습니다.')
|
|
const configPath = `${runtimeRoot}/docker/fastdds-peer-${peerAddress.replace(/[^0-9]/g, '-')}-d${parseRosDomainId(rosDomainId)}${allowCameraLan ? '-camera-lan' : ''}.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, lanAddresses)))} | 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',
|
|
...yahboomModuleCases(),
|
|
' *lms-nav2-camera-test*) PACKAGE=camera_test_pkg; LAUNCH=camera.launch.py ;;',
|
|
' *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; }',
|
|
...yahboomDevicePreflight(),
|
|
'CAMERA_DEVICE=""',
|
|
'CAMERA_DEVICES=()',
|
|
'if [[ "$MODULE_FOLDER" == *lms-nav2-camera-test* ]]; then',
|
|
' CAMERA_DEVICE=/dev/video0',
|
|
' for arg in "$@"; do case "$arg" in video_device:=*) CAMERA_DEVICE=${arg#video_device:=} ;; esac; done',
|
|
' if [ "$CAMERA_DEVICE" = /dev ]; then',
|
|
' CAMERA_DISCOVERY="$MODULE_ROOT/$MODULE_FOLDER/src/camera_test_pkg/camera_test_pkg/devices.py"',
|
|
' test -f "$CAMERA_DISCOVERY" || { echo "Update and rebuild the camera module for automatic discovery" >&2; exit 1; }',
|
|
' camera_devices=$(python3 "$CAMERA_DISCOVERY" /dev) || exit 1',
|
|
' mapfile -t CAMERA_DEVICES <<< "$camera_devices"',
|
|
' else CAMERA_DEVICES=("$CAMERA_DEVICE"); fi',
|
|
' for camera_device in "${CAMERA_DEVICES[@]}"; do',
|
|
' [[ "$camera_device" =~ ^/dev/video[0-9]+$ ]] && [ -c "$camera_device" ] || { echo "Camera device not found: $camera_device" >&2; exit 1; }',
|
|
' done',
|
|
'fi',
|
|
'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 [ -n "$YAHBOOM_DEVICE" ]; then args+=(--device "$YAHBOOM_DEVICE:$YAHBOOM_DEVICE"); fi',
|
|
'if [ -n "$CAMERA_DEVICE" ]; then',
|
|
' for camera_device in "${CAMERA_DEVICES[@]}"; do args+=(--device "$camera_device:$camera_device"); done',
|
|
' for capture_device in /dev/capture-vi-channel*; do',
|
|
' if [ -c "$capture_device" ]; then args+=(--device "$capture_device:$capture_device"); fi',
|
|
' done',
|
|
'fi',
|
|
'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',
|
|
...yahboomModuleCases(true),
|
|
' *lms-nav2-camera-test*) TARGET=camera_test_pkg ;;',
|
|
' *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<void> {
|
|
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<void> {
|
|
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<string> {
|
|
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<string> {
|
|
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<string> {
|
|
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<string | null> {
|
|
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<string> {
|
|
// 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', 'yahboom_gazebo']
|
|
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|yahboom|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 = `<sdf version="1.6"><model name="${id}"><static>true</static><link name="wall"><collision name="collision"><geometry><box><size>${length} ${depth} ${height}</size></box></geometry></collision><visual name="visual"><geometry><box><size>${length} ${depth} ${height}</size></box></geometry><material><ambient>0.85 0.38 0.04 1</ambient><diffuse>0.85 0.38 0.04 1</diffuse></material></visual></link></model></sdf>`
|
|
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 resolveLocalModuleRuntime(
|
|
moduleRoot: string,
|
|
folderName: string,
|
|
rosParams: string,
|
|
runtimeImages?: RuntimeImageArtifact[] | null,
|
|
): Promise<{ image: string; artifacts: RuntimeImageArtifact[] | null | undefined }> {
|
|
runtimeImages = cameraRuntimeArtifacts(folderName, runtimeImages)
|
|
if (yahboomSimulation(folderName, rosParams)) {
|
|
const artifact = runtimeImages?.find((item) =>
|
|
item.image?.trim() === defaultNav2GazeboRuntimeImage.image && item.url?.trim())
|
|
?? defaultNav2GazeboRuntimeImage
|
|
return { image: 'future-nav2-gazebo:humble', artifacts: [artifact] }
|
|
}
|
|
const image = await resolveCustomModuleRuntimeImage(
|
|
moduleRoot, resolveNav2RuntimeImage(runtimeImages, folderName, rosParams),
|
|
)
|
|
return {
|
|
image,
|
|
artifacts: folderName.includes('lms-nav2-camera-test')
|
|
? (runtimeImages ?? []).filter((item) => item.image?.trim() === image)
|
|
: yahboomRuntimeImage(folderName)
|
|
? yahboomRuntimeArtifacts(folderName, image, runtimeImages)
|
|
: requiredRuntimeImages(runtimeImages, image),
|
|
}
|
|
}
|
|
|
|
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 runtime = await resolveLocalModuleRuntime(moduleRoot, targetFolderName, rosParams, runtimeImages)
|
|
const runtimeImage = runtime.image
|
|
await prepareRuntimeImages(runtime.artifacts)
|
|
reportModuleProgress("모듈 실행 중...")
|
|
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) || rosNetwork?.allowExternal === true ? '0' : '1',
|
|
...(fastDdsConfigPath ? { FASTDDS_CONFIG_PATH: fastDdsConfigPath } : {}),
|
|
},
|
|
30000
|
|
)
|
|
return { ok: true, message: '실행 요청을 보냈습니다.' }
|
|
}
|
|
|
|
await prepareRuntimeImages(runtimeImages)
|
|
reportModuleProgress("모듈 실행 중...")
|
|
await ensureStudioRuntimeAssets()
|
|
const scriptPath = resolveAutowareDockerRunScript()
|
|
if (!existsSync(scriptPath)) throw new Error(`${scriptPath} 파일을 찾을 수 없습니다.`)
|
|
|
|
const statuses = await readDockerStatuses().catch(() => new Map<string, { running: boolean; exists: boolean; status: string }>())
|
|
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 runtime = await resolveLocalModuleRuntime(moduleRoot, targetFolderName, rosParams, runtimeImages)
|
|
const runtimeImage = runtime.image
|
|
await prepareRuntimeImages(runtime.artifacts)
|
|
reportModuleProgress("모듈 빌드 중...")
|
|
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)
|
|
reportModuleProgress("모듈 빌드 중...")
|
|
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<void> {
|
|
reportModuleProgress('이미지 준비 대기 중...')
|
|
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)',
|
|
' print("LMS_MODULE_PROGRESS:이미지 다운로드 중...", flush=True)',
|
|
' subprocess.run(["curl", "-fL", url, "-o", path], check=True)',
|
|
' print("LMS_MODULE_PROGRESS:이미지 파일 확인 중...", flush=True)',
|
|
' expected = (item.get("checksumSha256") or "").strip().lower()',
|
|
' if expected:',
|
|
' import hashlib',
|
|
' digest = hashlib.sha256()',
|
|
' with open(path, "rb") as fp:',
|
|
' for chunk in iter(lambda: fp.read(1024 * 1024), b""): digest.update(chunk)',
|
|
' actual = digest.hexdigest().lower()',
|
|
' if actual != expected: raise SystemExit(f"runtime image checksum mismatch: {name}")',
|
|
' print("LMS_MODULE_PROGRESS:이미지 불러오는 중...", flush=True)',
|
|
' 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)
|
|
runtimeImages = cameraRuntimeArtifacts(targetFolderName, runtimeImages)
|
|
const runtimeImage = nav2Module
|
|
? await resolveRemoteCustomModuleRuntimeImage(
|
|
target,
|
|
targetFolderName,
|
|
yahboomRuntimeImage(targetFolderName) ?? await resolveRemoteNav2RuntimeImage(runtimeImages),
|
|
)
|
|
: null
|
|
const runtimeRoot = nav2Module
|
|
? await ensureRemoteNav2RuntimeAssets(target)
|
|
: await ensureRemoteStudioRuntimeAssets(target)
|
|
const allowCameraLan = cameraLanEnabled(targetFolderName)
|
|
const fastDdsConfigPath = await prepareRemoteRosNetworkConfig(target, runtimeRoot, rosNetwork, rosDomainId, allowCameraLan)
|
|
await prepareRemoteRuntimeImages(target, targetFolderName.includes('lms-nav2-camera-test')
|
|
? (runtimeImages ?? []).filter((item) => item.image?.trim() === runtimeImage)
|
|
: yahboomRuntimeArtifacts(targetFolderName, runtimeImage, runtimeImages))
|
|
reportModuleProgress("모듈 실행 중...")
|
|
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) || allowCameraLan ? '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
|
|
runtimeImages = cameraRuntimeArtifacts(targetFolderName, runtimeImages)
|
|
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,
|
|
yahboomRuntimeImage(targetFolderName) ?? await resolveRemoteNav2RuntimeImage(runtimeImages),
|
|
)
|
|
await prepareRemoteRuntimeImages(target, targetFolderName.includes('lms-nav2-camera-test')
|
|
? (runtimeImages ?? []).filter((item) => item.image?.trim() === runtimeImage)
|
|
: yahboomRuntimeArtifacts(targetFolderName, runtimeImage, runtimeImages))
|
|
reportModuleProgress("모듈 빌드 중...")
|
|
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<string, { running: boolean; exists: boolean; status: string }>())
|
|
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<string, { running: boolean; exists: boolean; status: string }>())
|
|
const containerKeys = new Set<string>()
|
|
|
|
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 }> {
|
|
// Keep the legacy IPC contract without resetting student files or Git history.
|
|
return stopRemoteWorkspaceModules(targetInput, modules)
|
|
}
|
|
|
|
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<string, { running: boolean; exists: boolean; status: string }>())
|
|
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)} 2>&1`, 10000)
|
|
return { ok: true, logs }
|
|
} catch (error) {
|
|
return { ok: false, logs: '', message: error instanceof Error ? error.message : String(error) }
|
|
}
|
|
}
|
|
|
|
type AgentCommandResult = {
|
|
ok: boolean
|
|
output: string
|
|
exitCode: number | null
|
|
message?: string
|
|
}
|
|
|
|
const allowedAgentExecutables = new Set([
|
|
'bash', 'cat', 'cmake', 'colcon', 'find', 'git', 'grep', 'head', 'ls', 'make', 'ninja', 'node', 'npm', 'npx',
|
|
'pnpm', 'printenv', 'pwd', 'python', 'python3', 'pytest', 'rg', 'ros2', 'sed', 'sh', 'tail', 'which', 'yarn'
|
|
])
|
|
|
|
function validateAgentCommand(input: string): string {
|
|
const command = input.trim()
|
|
if (!command || command.length > 2000) throw new Error('명령 길이가 허용 범위를 벗어났습니다.')
|
|
if (/[\0\r\n`;{}<>]|\$\(|\.\.\//.test(command)) throw new Error('명령 치환, 경로 이탈 또는 리다이렉션은 허용되지 않습니다.')
|
|
if (/\b(?:sudo|su|shutdown|reboot|poweroff|halt|mkfs|fdisk|parted|mount|umount|passwd|useradd|userdel|chown)\b/i.test(command)) {
|
|
throw new Error('시스템 권한 또는 설정을 변경하는 명령은 허용되지 않습니다.')
|
|
}
|
|
if (/\brm\b|\b(?:curl|wget)\b[^|]*\|/i.test(command)) throw new Error('삭제 또는 원격 스크립트 실행 명령은 허용되지 않습니다.')
|
|
if (/(^|\s)(?:~\/(?:\.ssh|\.aws|\.config)|\/[^\s]*\/\.(?:ssh|aws)\/|\/etc\/|\/boot\/|\/proc\/|\/sys\/|\/dev\/|\.env(?:\s|$))/i.test(command)) {
|
|
throw new Error('시스템 또는 자격증명 경로에는 접근할 수 없습니다.')
|
|
}
|
|
|
|
const withoutAnd = command.replace(/&&|\|\|/g, '')
|
|
if (/[&]/.test(withoutAnd)) throw new Error('백그라운드 명령은 허용되지 않습니다.')
|
|
const segments = command.split(/&&|\|\||\|/).map((segment) => segment.trim()).filter(Boolean)
|
|
for (const segment of segments) {
|
|
const tokens = segment.split(/\s+/).filter(Boolean)
|
|
while (tokens[0] && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[0])) tokens.shift()
|
|
const executable = tokens[0]
|
|
if (!executable) throw new Error('실행할 명령을 확인할 수 없습니다.')
|
|
if (executable === 'source' || executable === '.') {
|
|
const sourcePath = tokens[1] ?? ''
|
|
if (!/^(?:\/opt\/ros\/[A-Za-z0-9._-]+\/setup\.bash|\.?\/?(?:install|build)\/setup\.bash)$/.test(sourcePath)) {
|
|
throw new Error('허용되지 않은 환경 설정 파일입니다.')
|
|
}
|
|
continue
|
|
}
|
|
if (executable.startsWith('./')) {
|
|
if (!/^\.\/[A-Za-z0-9_./-]+\.sh$/.test(executable)) throw new Error('모듈 내부의 셸 스크립트만 실행할 수 있습니다.')
|
|
continue
|
|
}
|
|
if (!allowedAgentExecutables.has(executable)) throw new Error(`허용되지 않은 실행 파일입니다: ${executable}`)
|
|
if (executable === 'bash' || executable === 'sh') {
|
|
const scriptPath = tokens[1] ?? ''
|
|
if (!/^(?:\.\/)?[A-Za-z0-9_./-]+\.sh$/.test(scriptPath)) {
|
|
throw new Error('bash와 sh는 모듈 내부의 셸 스크립트만 실행할 수 있습니다.')
|
|
}
|
|
}
|
|
if ((executable === 'python' || executable === 'python3') && tokens.some((token) => token === '-c')) {
|
|
throw new Error('인라인 Python 코드는 실행할 수 없습니다.')
|
|
}
|
|
if (executable === 'node' && tokens.some((token) => token === '-e' || token === '--eval')) {
|
|
throw new Error('인라인 Node.js 코드는 실행할 수 없습니다.')
|
|
}
|
|
if (executable === 'find' && tokens.some((token) => token === '-exec' || token === '-execdir' || token === '-delete')) {
|
|
throw new Error('find의 실행 및 삭제 옵션은 허용되지 않습니다.')
|
|
}
|
|
if (executable === 'sed' && tokens.some((token) => token === '-i' || token.startsWith('-i'))) {
|
|
throw new Error('명령을 통한 파일 직접 수정은 허용되지 않습니다.')
|
|
}
|
|
if (executable === 'git' && !/^(?:status|diff|log|show|rev-parse|branch)$/.test(tokens[1] ?? '')) {
|
|
throw new Error('Agent는 Git 조회 명령만 실행할 수 있습니다.')
|
|
}
|
|
if (executable === 'ros2' && !/^(?:topic|node|interface|doctor|pkg|run|launch)$/.test(tokens[1] ?? '')) {
|
|
throw new Error('허용되지 않은 ROS2 명령입니다.')
|
|
}
|
|
if (executable === 'ros2' && /\b(?:pub|call|send_goal)\b/.test(segment)) {
|
|
throw new Error('ROS2 발행 및 제어 명령은 Agent에서 실행할 수 없습니다.')
|
|
}
|
|
}
|
|
return command
|
|
}
|
|
|
|
function appendAgentCommandOutput(current: string, chunk: string): string {
|
|
const next = current + chunk
|
|
return next.length > 80_000 ? next.slice(-80_000) : next
|
|
}
|
|
|
|
function isAgentRos2Command(command: string): boolean {
|
|
return /(?:^|&&|\|\|?|;)\s*ros2\s+/.test(command)
|
|
}
|
|
|
|
function isAgentRos2RuntimeCommand(command: string): boolean {
|
|
return /(?:^|&&|\|\|?|;)\s*ros2\s+(?:run|launch)\b/.test(command)
|
|
}
|
|
|
|
function agentContainerCommandArgs(container: { id: string; cwd: string }, command: string, observeRuntime: boolean): string[] {
|
|
const init = [
|
|
'if [ -n "$ROS_DISTRO" ] && [ -f "/opt/ros/$ROS_DISTRO/setup.bash" ]; then source "/opt/ros/$ROS_DISTRO/setup.bash"; fi',
|
|
'if [ -f ./install/setup.bash ]; then source ./install/setup.bash; fi',
|
|
command
|
|
].join('; ')
|
|
return [
|
|
'exec', '-w', container.cwd, container.id,
|
|
...(observeRuntime ? ['timeout', '--signal=INT', '--kill-after=5s', '20s'] : []),
|
|
'bash', '--noprofile', '--norc', '-lc', init
|
|
]
|
|
}
|
|
|
|
async function resolveLocalAgentContainer(moduleRoot: string, folderName?: string | null): Promise<{ id: string; cwd: string }> {
|
|
const info = parseModuleDockerInfo(folderName?.trim() || basename(moduleRoot))
|
|
if (!info || !isAllowedContainerName(info.containerKey)) throw new Error('모듈 컨테이너 정보를 확인할 수 없습니다.')
|
|
const inspect = await runCommandOutput('docker', ['inspect', info.containerKey], undefined, undefined, 10_000)
|
|
return resolveContainerTerminal(inspect, moduleRoot)
|
|
}
|
|
|
|
async function resolveRemoteAgentContainer(
|
|
target: ReturnType<typeof normalizeRemoteTarget>,
|
|
moduleRoot: string,
|
|
folderName?: string | null
|
|
): Promise<{ id: string; cwd: string }> {
|
|
const info = parseModuleDockerInfo(folderName?.trim() || basename(moduleRoot))
|
|
if (!info || !isAllowedContainerName(info.containerKey)) throw new Error('모듈 컨테이너 정보를 확인할 수 없습니다.')
|
|
const inspect = await runSshOutput(target, `docker inspect ${quoteShellSingle(info.containerKey)}`, 10_000)
|
|
return resolveContainerTerminal(inspect, moduleRoot)
|
|
}
|
|
|
|
async function runLocalAgentCommand(moduleName: string, commandInput: string, workspaceRoot?: string | null, folderName?: string | null): Promise<AgentCommandResult> {
|
|
const command = validateAgentCommand(commandInput)
|
|
const moduleRoot = await findWorkspaceModuleRoot(moduleName, workspaceRoot, folderName)
|
|
if (!moduleRoot) throw new Error('설치된 모듈을 찾을 수 없습니다.')
|
|
|
|
const observeRuntime = isAgentRos2RuntimeCommand(command)
|
|
const container = isAgentRos2Command(command) ? await resolveLocalAgentContainer(moduleRoot, folderName) : null
|
|
const executable = container ? 'docker' : '/bin/bash'
|
|
const args = container
|
|
? agentContainerCommandArgs(container, command, observeRuntime)
|
|
: ['--noprofile', '--norc', '-lc', command]
|
|
|
|
return new Promise<AgentCommandResult>((resolvePromise) => {
|
|
let output = ''
|
|
let timedOut = false
|
|
const child = spawn(executable, args, {
|
|
cwd: container ? undefined : moduleRoot,
|
|
detached: process.platform !== 'win32',
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }
|
|
})
|
|
const timeout = setTimeout(() => {
|
|
timedOut = true
|
|
if (process.platform !== 'win32' && child.pid) {
|
|
try { process.kill(-child.pid, 'SIGTERM') } catch { /* already exited */ }
|
|
} else child.kill('SIGTERM')
|
|
}, observeRuntime ? 30_000 : 120_000)
|
|
child.stdout.setEncoding('utf8')
|
|
child.stderr.setEncoding('utf8')
|
|
child.stdout.on('data', (data: string) => { output = appendAgentCommandOutput(output, data) })
|
|
child.stderr.on('data', (data: string) => { output = appendAgentCommandOutput(output, data) })
|
|
child.on('error', (error) => {
|
|
clearTimeout(timeout)
|
|
resolvePromise({ ok: false, output, exitCode: null, message: error.message })
|
|
})
|
|
child.on('close', (code) => {
|
|
clearTimeout(timeout)
|
|
const exitCode = typeof code === 'number' ? code : null
|
|
const observationCompleted = observeRuntime && exitCode === 124
|
|
if (observationCompleted) {
|
|
output = appendAgentCommandOutput(output, '\n[20초 동안 실행을 확인한 뒤 프로세스를 종료했습니다.]\n')
|
|
}
|
|
resolvePromise({
|
|
ok: !timedOut && (exitCode === 0 || observationCompleted),
|
|
output,
|
|
exitCode: observationCompleted ? 0 : exitCode,
|
|
message: timedOut ? `명령 실행 시간이 ${observeRuntime ? 30 : 120}초를 초과했습니다.` : undefined
|
|
})
|
|
})
|
|
})
|
|
}
|
|
|
|
async function runRemoteAgentCommand(targetInput: RemoteWorkspaceTarget, moduleName: string, commandInput: string, folderName?: string | null): Promise<AgentCommandResult> {
|
|
const command = validateAgentCommand(commandInput)
|
|
const resolved = await resolveRemoteGitModuleRoot(targetInput, moduleName, folderName)
|
|
const marker = '__AUTONOMY_AGENT_EXIT_CODE__:'
|
|
try {
|
|
const observeRuntime = isAgentRos2RuntimeCommand(command)
|
|
const container = isAgentRos2Command(command)
|
|
? await resolveRemoteAgentContainer(resolved.target, resolved.moduleRoot, folderName)
|
|
: null
|
|
const invocation = container
|
|
? ['docker', ...agentContainerCommandArgs(container, command, observeRuntime)].map(quoteShellSingle).join(' ')
|
|
: `bash --noprofile --norc -lc ${quoteShellSingle(command)}`
|
|
const rawOutput = await runSshOutput(resolved.target, [
|
|
`cd ${quoteShellSingle(resolved.moduleRoot)}`,
|
|
`${invocation} 2>&1`,
|
|
'CODE=$?',
|
|
`printf '\\n${marker}%s\\n' "$CODE"`,
|
|
].join('; '), observeRuntime ? 35_000 : 130_000)
|
|
const markerIndex = rawOutput.lastIndexOf(marker)
|
|
const exitCode = markerIndex >= 0 ? Number.parseInt(rawOutput.slice(markerIndex + marker.length).trim(), 10) : null
|
|
const observationCompleted = observeRuntime && exitCode === 124
|
|
let output = appendAgentCommandOutput('', markerIndex >= 0 ? rawOutput.slice(0, markerIndex).trimEnd() : rawOutput)
|
|
if (observationCompleted) output = appendAgentCommandOutput(output, '\n[20초 동안 실행을 확인한 뒤 프로세스를 종료했습니다.]')
|
|
return {
|
|
ok: exitCode === 0 || observationCompleted,
|
|
output,
|
|
exitCode: observationCompleted ? 0 : exitCode,
|
|
message: markerIndex < 0 ? '원격 명령의 종료 코드를 확인하지 못했습니다.' : undefined
|
|
}
|
|
} catch (error) {
|
|
return { ok: false, output: '', exitCode: null, message: error instanceof Error ? error.message : String(error) }
|
|
}
|
|
}
|
|
|
|
function agentFolder(folderName?: string | null): string | undefined {
|
|
if (folderName == null) return undefined
|
|
if (!/^[A-Za-z0-9[\](). _-]{1,160}$/.test(folderName) || folderName === '.' || folderName === '..') throw new Error('모듈 폴더명이 올바르지 않습니다.')
|
|
return folderName
|
|
}
|
|
|
|
async function runWorkspaceAgentTool(input: AgentToolRequest, workspaceRoot?: string, folderName?: string, remote?: RemoteWorkspaceTarget): Promise<AgentToolResult> {
|
|
const request = validateAgentTool(input)
|
|
const target = remote ? `ssh:${normalizeRemoteTarget(remote).host}` : 'local-host'
|
|
const result: AgentToolResult = { id: request.id, name: request.name, moduleName: request.moduleName, status: 'ok', observedAt: new Date().toISOString(), target, data: '', truncated: false }
|
|
try {
|
|
const folder = agentFolder(folderName)
|
|
const resolved = remote ? await resolveRemoteGitModuleRoot(remote, request.moduleName!, folder) : null
|
|
const root = resolved?.moduleRoot ?? await findWorkspaceModuleRoot(request.moduleName!, workspaceRoot, folder)
|
|
if (!root) throw new Error('설치된 모듈을 찾을 수 없습니다.')
|
|
let payload: unknown
|
|
if (request.name.startsWith('files.')) {
|
|
const response = remote
|
|
? JSON.parse(await runUnqueuedSshOutput(resolved!.target, agentRemoteFilesScript({ root, request }), 30_000))
|
|
: await runAgentFileTool(root, request)
|
|
payload = response.data
|
|
result.truncated = response.truncated
|
|
} else if (request.name === 'logs.read') {
|
|
const response = remote
|
|
? await readRemoteWorkspaceModuleLogs(remote, request.moduleName!, folder)
|
|
: await readWorkspaceModuleLogs(request.moduleName!, workspaceRoot, folder)
|
|
payload = { ...response, logs: response.logs.slice(-20_000) }
|
|
if (!response.ok) result.status = 'error'
|
|
result.truncated = response.logs.length > 20_000
|
|
} else {
|
|
const host = (executable: string, args: string[]): Promise<string> => remote
|
|
? runUnqueuedSshOutput(resolved!.target, [executable, ...args].map(quoteShellSingle).join(' '), 12_000)
|
|
: runCommandOutput(executable, args, undefined, undefined, 12_000, 'stdout')
|
|
const container = async (command: string): Promise<string> => {
|
|
const runtime = remote ? await resolveRemoteAgentContainer(resolved!.target, root, folder) : await resolveLocalAgentContainer(root, folder)
|
|
const args = agentContainerCommandArgs(runtime, command, false)
|
|
// Unlike free-form commands, these fixed probes always have a container-side deadline.
|
|
args.splice(4, 0, 'timeout', '--signal=INT', '--kill-after=2s', '10s')
|
|
return host('docker', args)
|
|
}
|
|
payload = await inspectAgentRuntime(request, host, container)
|
|
if (request.name === 'network.inspect' && (payload as { container?: { error?: string } }).container?.error) result.truncated = true
|
|
if (request.name === 'ros.inspect') result.target += ':module-container'
|
|
}
|
|
result.data = JSON.stringify(payload)
|
|
if (result.data.length > 60_000) {
|
|
result.data = JSON.stringify({ message: '조회 결과가 용량 한도를 초과했습니다. 파일 범위나 검색어를 좁혀주세요.', excerpt: result.data.slice(0, 20_000) })
|
|
result.truncated = true
|
|
}
|
|
} catch (error) {
|
|
result.status = 'error'
|
|
result.data = JSON.stringify({ error: (error instanceof Error ? error.message : String(error)).slice(-2000) })
|
|
}
|
|
return result
|
|
}
|
|
|
|
let agentFileApplyPending = false
|
|
async function applyWorkspaceAgentFiles(changes: AgentCheckedChange[], workspaceRoot?: string, remote?: RemoteWorkspaceTarget): Promise<{ ok: boolean }> {
|
|
if (agentFileApplyPending) throw new Error('다른 파일 변경안을 적용 중입니다.')
|
|
agentFileApplyPending = true
|
|
try { return await applyWorkspaceAgentFilesOnce(changes, workspaceRoot, remote) }
|
|
finally { agentFileApplyPending = false }
|
|
}
|
|
|
|
async function applyWorkspaceAgentFilesOnce(changes: AgentCheckedChange[], workspaceRoot?: string, remote?: RemoteWorkspaceTarget): Promise<{ ok: boolean }> {
|
|
if (!Array.isArray(changes) || !changes.length || changes.length > 24) throw new Error('변경 파일 수를 확인하세요.')
|
|
const resolved: Array<AgentCheckedChange & { root: string }> = []
|
|
for (const change of changes) {
|
|
if (!change || typeof change.content !== 'string' || change.content.length > 200_000 || (change.expectedHash !== null && !/^[a-f0-9]{64}$/.test(change.expectedHash))) throw new Error('검증된 파일 변경안이 아닙니다.')
|
|
validateAgentTool({ id: 'apply-check', name: 'files.read', moduleName: change.moduleName, args: { path: change.path } })
|
|
const folder = agentFolder(change.folderName)
|
|
const root = remote ? (await resolveRemoteGitModuleRoot(remote, change.moduleName, folder)).moduleRoot : await findWorkspaceModuleRoot(change.moduleName, workspaceRoot, folder)
|
|
if (!root) throw new Error(`변경 대상 모듈이 설치되지 않았습니다: ${change.moduleName}`)
|
|
resolved.push({ ...change, root })
|
|
}
|
|
if (remote) await runUnqueuedSshOutput(remote, 'bash --noprofile --norc -s', 30_000, agentRemoteFilesScript({ changes: resolved }))
|
|
else await applyAgentFiles(resolved)
|
|
return { ok: true }
|
|
}
|
|
|
|
async function collectModuleFiles(moduleName: string, workspaceRoot?: string | null, folderName?: string | null): Promise<CachedModuleFiles> {
|
|
const rootPath = await findModuleRoot(moduleName, workspaceRoot, folderName)
|
|
if (!rootPath) return { root: null, absoluteRoot: null, files: {}, folders: [] }
|
|
|
|
const files: Record<string, string> = {}
|
|
const folders: string[] = []
|
|
const visit = async (currentPath: string, depth: number): Promise<void> => {
|
|
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<CachedModuleFiles> {
|
|
const { target, moduleRoot } = await resolveRemoteGitModuleRoot(targetInput, moduleName, folderName)
|
|
const output = await runUnqueuedSshOutput(target, remoteModuleFilesScript(moduleRoot, { action: 'list' }), 30000)
|
|
const listing = JSON.parse(output) as { filePaths: string[]; folders: string[] }
|
|
const prefix = (path: string): string => `modules/${moduleName}/${path}`
|
|
return { root: moduleRoot.split('/').pop() ?? moduleName, absoluteRoot: moduleRoot,
|
|
files: {}, filePaths: listing.filePaths.map(prefix), folders: listing.folders.map(prefix) }
|
|
}
|
|
|
|
async function collectRootFiles(rootPath: string, rendererRoot: string): Promise<CachedModuleFiles> {
|
|
const files: Record<string, string> = {}
|
|
const folders: string[] = []
|
|
const visit = async (currentPath: string, depth: number): Promise<void> => {
|
|
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<string> {
|
|
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<typeof normalizeRemoteTarget>; 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<string> {
|
|
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<string, string> {
|
|
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 {
|
|
intentionalTerminalStops.delete(terminalKey)
|
|
terminalProcesses.set(terminalKey, proc)
|
|
proc.onData((data) => {
|
|
if (!sender.isDestroyed()) sender.send('terminal-data', sessionId, data)
|
|
})
|
|
proc.onExit(({ exitCode }) => {
|
|
if (terminalProcesses.get(terminalKey) !== proc) return
|
|
terminalProcesses.delete(terminalKey)
|
|
if (intentionalTerminalStops.delete(terminalKey)) return
|
|
if (!sender.isDestroyed()) sender.send('terminal-data', sessionId, `\r\n[terminal exited: ${exitCode}]\r\n`)
|
|
})
|
|
}
|
|
|
|
async function containerTerminalCommand(moduleRoot: string, moduleName: string | undefined, cols: number, rows: number, target?: ReturnType<typeof normalizeRemoteTarget>): Promise<string> {
|
|
const info = parseModuleDockerInfo(basename(moduleRoot))
|
|
if (!info || !isAllowedContainerName(info.containerKey)) throw new Error('Invalid module container name')
|
|
const output = target
|
|
? await runSshOutput(target, `docker inspect ${quoteShellSingle(info.containerKey)}`, 10000)
|
|
: await runCommandOutput('docker', ['inspect', info.containerKey], undefined, undefined, 10000)
|
|
const container = resolveContainerTerminal(output, moduleRoot)
|
|
const shell = buildWorkspaceShellCommand(moduleName, container.cwd, cols, rows, false)
|
|
const init = 'if [ -n "$ROS_DISTRO" ] && [ -f "/opt/ros/$ROS_DISTRO/setup.bash" ]; then source "/opt/ros/$ROS_DISTRO/setup.bash"; fi; if [ -f ./install/setup.bash ]; then source ./install/setup.bash; fi; exec ' + shell
|
|
const containerShell = target ? remoteShellCommand(init) : `/bin/bash --noprofile --norc -c ${quoteShellSingle(init)}`
|
|
return `exec docker exec -it -e TERM=xterm-256color -w ${quoteShellSingle(container.cwd)} ${quoteShellSingle(container.id)} ${containerShell}`
|
|
}
|
|
|
|
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, mode: 'host' | 'container' = 'host') => {
|
|
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 command = mode === 'container'
|
|
? await containerTerminalCommand(terminalCwd, moduleName, safeCols, safeRows)
|
|
: buildWorkspaceShellCommand(moduleName, terminalCwd, safeCols, safeRows, false)
|
|
const proc = pty.spawn('/bin/bash', ['--noprofile', '--norc', '-c', command], {
|
|
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, mode: 'host' | 'container' = 'host') => {
|
|
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}`,
|
|
remoteShellCommand(mode === 'container'
|
|
? await containerTerminalCommand(resolved.moduleRoot, moduleName, safeCols, safeRows, target)
|
|
: buildWorkspaceShellCommand(moduleName, resolved.moduleRoot, safeCols, safeRows, true))
|
|
], {
|
|
name: 'xterm-256color',
|
|
cols: safeCols,
|
|
rows: safeRows,
|
|
env: Object.fromEntries(Object.entries(remoteShellEnvironment(terminalEnvironment(safeCols, safeRows)))
|
|
.filter((entry): entry is [string, string] => typeof entry[1] === 'string'))
|
|
})
|
|
|
|
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', () => listWorkspaceDockerImages())
|
|
ipcMain.handle('local-workspace:modules:git-sources', (_event, root: string) => {
|
|
const workspace = expandUserPath(root)
|
|
if (!workspace) throw new Error('워크스페이스 경로가 설정되어 있지 않습니다.')
|
|
return listLocalGitModules(workspace)
|
|
})
|
|
ipcMain.handle('local-workspace:modules:git-source', (_event, root: string, folder: string) => inspectLocalGitModule(root, folder))
|
|
ipcMain.handle('remote-workspace:modules:import-git', (_event, target: RemoteWorkspaceTarget, root: string, input: GitModuleImportInput & { folderName: string }) => importRemoteGitModule(target, root, input))
|
|
ipcMain.handle('remote-workspace:modules:docker-images', (_event, target: RemoteWorkspaceTarget) => listWorkspaceDockerImages(target))
|
|
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))
|
|
handleModuleOperation('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))
|
|
handleModuleOperation('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:agent-command:run', (_event, moduleName: string, command: string, workspaceRoot?: string | null, folderName?: string | null) => runLocalAgentCommand(moduleName, command, workspaceRoot, folderName))
|
|
const checkAgentSender = (event: Electron.IpcMainInvokeEvent): void => {
|
|
if (event.sender !== mainWindow?.webContents || event.senderFrame !== event.sender.mainFrame) throw new Error('허용되지 않은 에이전트 요청입니다.')
|
|
}
|
|
ipcMain.handle('local-workspace:agent-tool:read', (event, request: AgentToolRequest, workspaceRoot?: string, folderName?: string) => {
|
|
checkAgentSender(event)
|
|
return runWorkspaceAgentTool(request, workspaceRoot, folderName)
|
|
})
|
|
ipcMain.handle('remote-workspace:agent-tool:read', (event, target: RemoteWorkspaceTarget, request: AgentToolRequest, folderName?: string) => {
|
|
checkAgentSender(event)
|
|
return runWorkspaceAgentTool(request, undefined, folderName, target)
|
|
})
|
|
ipcMain.handle('local-workspace:agent-files:apply', (event, changes: AgentCheckedChange[], workspaceRoot?: string) => {
|
|
checkAgentSender(event)
|
|
return applyWorkspaceAgentFiles(changes, workspaceRoot)
|
|
})
|
|
ipcMain.handle('remote-workspace:agent-files:apply', (event, target: RemoteWorkspaceTarget, changes: AgentCheckedChange[]) => {
|
|
checkAgentSender(event)
|
|
return applyWorkspaceAgentFiles(changes, undefined, target)
|
|
})
|
|
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)
|
|
})
|
|
handleModuleOperation('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))
|
|
handleModuleOperation('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:agent-command:run', (_event, target: RemoteWorkspaceTarget, moduleName: string, command: string, folderName?: string | null) => runRemoteAgentCommand(target, moduleName, command, 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, requestId?: string) => visualizationProgress.run((message) => { if (!_event.sender.isDestroyed()) _event.sender.send('visualization:progress', requestId, message) }, () => 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:content', async (_event, target: RemoteWorkspaceTarget, moduleName: string, path: string, folderName?: string | null) => {
|
|
const resolved = await resolveRemoteRendererModulePath(target, moduleName, path, folderName)
|
|
return JSON.parse(await runUnqueuedSshOutput(resolved.target, remoteModuleFilesScript(resolved.moduleRoot, { action: 'content', path: resolved.relativePath }), 30000))
|
|
})
|
|
ipcMain.handle('remote-workspace:module-files:search', async (_event, target: RemoteWorkspaceTarget, moduleName: string, query: string, folderName?: string | null) => {
|
|
const resolved = await resolveRemoteGitModuleRoot(target, moduleName, folderName)
|
|
const results = JSON.parse(await runUnqueuedSshOutput(resolved.target, remoteModuleFilesScript(resolved.moduleRoot, { action: 'search', query }), 30000)) as Array<{ file: string; lineNumber: number; lineText: string }>
|
|
return results.map((result) => ({ ...result, file: `modules/${moduleName}/${result.file}` }))
|
|
})
|
|
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, requestId?: string) => visualizationProgress.run((message) => { if (!_event.sender.isDestroyed()) _event.sender.send('visualization:progress', requestId, message) }, () => 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.handle('chat-text:context-menu', showChatTextMenu)
|
|
ipcMain.handle('chat-text:clipboard-state', readChatClipboardState)
|
|
ipcMain.handle('chat-text:clipboard-action', performChatClipboardAction)
|
|
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))
|
|
handleModuleOperation('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))
|
|
handleModuleOperation('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()
|
|
})
|