1469 lines
51 KiB
TypeScript
1469 lines
51 KiB
TypeScript
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import { exec } from 'node:child_process';
|
|
import { createHash } from 'node:crypto';
|
|
import { createReadStream } from 'node:fs';
|
|
import { copyFile, mkdir, readFile, rm, stat } from 'node:fs/promises';
|
|
import { basename } from 'node:path';
|
|
import { promisify } from 'node:util';
|
|
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
|
|
|
|
import { LauncherPrismaService } from '../launcher-prisma/launcher-prisma.service';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import {
|
|
CreateLauncherAppLinkDto,
|
|
CreateLauncherBuildJobDto,
|
|
CreateLauncherCommonSettingDto,
|
|
CreateLauncherVersionDto,
|
|
UpdateLauncherSourceDto,
|
|
LinkLauncherVersionSourceDto,
|
|
UpdateLauncherAppLinkDto,
|
|
UpdateLauncherCommonSettingDto,
|
|
UpdateLauncherUserAppPermissionsDto,
|
|
} from './dto';
|
|
|
|
const execAsync = promisify(exec);
|
|
const LAUNCHER_SOURCE_SETTING_KEYS = {
|
|
repository: 'launcher.client.source.repository',
|
|
branch: 'launcher.client.source.branch',
|
|
commitSha: 'launcher.client.source.commit_sha',
|
|
backendRepository: 'launcher.backend.source.repository',
|
|
backendBranch: 'launcher.backend.source.branch',
|
|
backendCommitSha: 'launcher.backend.source.commit_sha',
|
|
backendImage: 'launcher.backend.image',
|
|
backendDockerfile: 'launcher.backend.dockerfile',
|
|
} as const;
|
|
|
|
@Injectable()
|
|
export class LauncherService {
|
|
private r2Client?: S3Client;
|
|
private giteaUsername?: string;
|
|
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly launcherPrisma: LauncherPrismaService,
|
|
private readonly configService: ConfigService,
|
|
) {}
|
|
|
|
async listVersions() {
|
|
const rows = await this.launcherPrisma.$queryRawUnsafe<LauncherVersionRow[]>(`
|
|
SELECT id, app_key, display_name, latest_version, minimum_supported_version, download_url, release_notes, repository, branch, commit_sha, is_enabled, created_at, updated_at
|
|
FROM launcher_app_versions
|
|
WHERE app_key = 'launcher'
|
|
ORDER BY updated_at DESC
|
|
`);
|
|
|
|
return rows.map(mapLauncherVersionRow);
|
|
}
|
|
|
|
|
|
listCommonSettings() {
|
|
return this.launcherPrisma.launcherCommonSetting.findMany({
|
|
orderBy: [{ isActive: 'desc' }, { type: 'asc' }, { name: 'asc' }],
|
|
});
|
|
}
|
|
|
|
async createCommonSetting(body: CreateLauncherCommonSettingDto) {
|
|
const setting = await this.launcherPrisma.launcherCommonSetting.create({
|
|
data: {
|
|
key: body.key.trim(),
|
|
name: body.name.trim(),
|
|
type: body.type.trim(),
|
|
value: body.value.trim(),
|
|
description: body.description?.trim() || null,
|
|
isActive: body.isActive ?? true,
|
|
isSecret: body.isSecret ?? false,
|
|
},
|
|
});
|
|
|
|
return { setting };
|
|
}
|
|
|
|
async updateCommonSetting(id: string, body: UpdateLauncherCommonSettingDto) {
|
|
const setting = await this.launcherPrisma.launcherCommonSetting.update({
|
|
where: { id },
|
|
data: {
|
|
key: body.key === undefined ? undefined : body.key.trim(),
|
|
name: body.name === undefined ? undefined : body.name.trim(),
|
|
type: body.type === undefined ? undefined : body.type.trim(),
|
|
value: body.value === undefined ? undefined : body.value.trim(),
|
|
description: body.description === undefined ? undefined : body.description.trim() || null,
|
|
isActive: body.isActive,
|
|
isSecret: body.isSecret,
|
|
},
|
|
});
|
|
|
|
return { setting };
|
|
}
|
|
|
|
async deleteCommonSetting(id: string) {
|
|
await this.launcherPrisma.launcherCommonSetting.delete({ where: { id } });
|
|
return { deleted: true };
|
|
}
|
|
|
|
async getSource() {
|
|
const source = await this.getSourceSettings();
|
|
return { source };
|
|
}
|
|
|
|
async updateSource(body: UpdateLauncherSourceDto) {
|
|
const target = body.target ?? 'launcher-client';
|
|
const repository = normalizeRepository(body.repository);
|
|
const branch = body.branch?.trim() || 'release';
|
|
const commitSha = body.commitSha?.trim() || '';
|
|
const isBackend = target === 'launcher-backend';
|
|
const image = body.image?.trim() || this.configService.get<string>('LAUNCHER_BACKEND_IMAGE', 'localhost:5000/studio-launcher-be');
|
|
const dockerfile = body.dockerfile?.trim() || 'Dockerfile';
|
|
|
|
const writes = isBackend ? [
|
|
this.upsertCommonSettingByKey(
|
|
LAUNCHER_SOURCE_SETTING_KEYS.backendRepository,
|
|
'런처 백엔드 소스 저장소',
|
|
'source',
|
|
repository,
|
|
'런처 백엔드 컨테이너 빌드에 사용할 저장소',
|
|
),
|
|
this.upsertCommonSettingByKey(
|
|
LAUNCHER_SOURCE_SETTING_KEYS.backendBranch,
|
|
'런처 백엔드 소스 브랜치',
|
|
'source',
|
|
branch,
|
|
'런처 백엔드 컨테이너 빌드 기준 브랜치',
|
|
),
|
|
this.upsertCommonSettingByKey(
|
|
LAUNCHER_SOURCE_SETTING_KEYS.backendCommitSha,
|
|
'런처 백엔드 소스 커밋',
|
|
'source',
|
|
commitSha,
|
|
'비워두면 브랜치 최신 커밋 기준',
|
|
),
|
|
this.upsertCommonSettingByKey(
|
|
LAUNCHER_SOURCE_SETTING_KEYS.backendImage,
|
|
'런처 백엔드 이미지',
|
|
'source',
|
|
image,
|
|
'런처 백엔드 컨테이너 이미지 이름',
|
|
),
|
|
this.upsertCommonSettingByKey(
|
|
LAUNCHER_SOURCE_SETTING_KEYS.backendDockerfile,
|
|
'런처 백엔드 Dockerfile',
|
|
'source',
|
|
dockerfile,
|
|
'런처 백엔드 빌드에 사용할 Dockerfile 경로',
|
|
),
|
|
] : [
|
|
this.upsertCommonSettingByKey(
|
|
LAUNCHER_SOURCE_SETTING_KEYS.repository,
|
|
'런처 소스 저장소',
|
|
'source',
|
|
repository,
|
|
'런처 클라이언트 빌드에 사용할 저장소',
|
|
),
|
|
this.upsertCommonSettingByKey(
|
|
LAUNCHER_SOURCE_SETTING_KEYS.branch,
|
|
'런처 소스 브랜치',
|
|
'source',
|
|
branch,
|
|
'런처 클라이언트 빌드 기준 브랜치',
|
|
),
|
|
this.upsertCommonSettingByKey(
|
|
LAUNCHER_SOURCE_SETTING_KEYS.commitSha,
|
|
'런처 소스 커밋',
|
|
'source',
|
|
commitSha,
|
|
'비워두면 브랜치 최신 커밋 기준',
|
|
),
|
|
];
|
|
|
|
await this.launcherPrisma.$transaction(writes);
|
|
|
|
return {
|
|
source: {
|
|
repository,
|
|
branch,
|
|
commitSha: commitSha || null,
|
|
image: isBackend ? image : undefined,
|
|
dockerfile: isBackend ? dockerfile : undefined,
|
|
},
|
|
};
|
|
}
|
|
|
|
private async getSourceSettings() {
|
|
const rows = await this.launcherPrisma.launcherCommonSetting.findMany({
|
|
where: {
|
|
key: {
|
|
in: [
|
|
LAUNCHER_SOURCE_SETTING_KEYS.repository,
|
|
LAUNCHER_SOURCE_SETTING_KEYS.branch,
|
|
LAUNCHER_SOURCE_SETTING_KEYS.commitSha,
|
|
LAUNCHER_SOURCE_SETTING_KEYS.backendRepository,
|
|
LAUNCHER_SOURCE_SETTING_KEYS.backendBranch,
|
|
LAUNCHER_SOURCE_SETTING_KEYS.backendCommitSha,
|
|
LAUNCHER_SOURCE_SETTING_KEYS.backendImage,
|
|
LAUNCHER_SOURCE_SETTING_KEYS.backendDockerfile,
|
|
],
|
|
},
|
|
},
|
|
});
|
|
const byKey = new Map(rows.map((row) => [row.key, row.value]));
|
|
const repository = byKey.get(LAUNCHER_SOURCE_SETTING_KEYS.repository) || null;
|
|
const branch = byKey.get(LAUNCHER_SOURCE_SETTING_KEYS.branch) || 'release';
|
|
const commitSha = byKey.get(LAUNCHER_SOURCE_SETTING_KEYS.commitSha) || null;
|
|
const backendRepository = byKey.get(LAUNCHER_SOURCE_SETTING_KEYS.backendRepository) || null;
|
|
const backendBranch = byKey.get(LAUNCHER_SOURCE_SETTING_KEYS.backendBranch) || 'release';
|
|
const backendCommitSha = byKey.get(LAUNCHER_SOURCE_SETTING_KEYS.backendCommitSha) || null;
|
|
const backendImage = byKey.get(LAUNCHER_SOURCE_SETTING_KEYS.backendImage) || this.configService.get<string>('LAUNCHER_BACKEND_IMAGE', 'localhost:5000/studio-launcher-be');
|
|
const backendDockerfile = byKey.get(LAUNCHER_SOURCE_SETTING_KEYS.backendDockerfile) || 'Dockerfile';
|
|
|
|
return {
|
|
repository,
|
|
branch,
|
|
commitSha,
|
|
client: {
|
|
repository,
|
|
branch,
|
|
commitSha,
|
|
},
|
|
backend: {
|
|
repository: backendRepository,
|
|
branch: backendBranch,
|
|
commitSha: backendCommitSha,
|
|
image: backendImage,
|
|
dockerfile: backendDockerfile,
|
|
},
|
|
};
|
|
}
|
|
|
|
private upsertCommonSettingByKey(
|
|
key: string,
|
|
name: string,
|
|
type: string,
|
|
value: string,
|
|
description: string,
|
|
) {
|
|
return this.launcherPrisma.launcherCommonSetting.upsert({
|
|
where: { key },
|
|
create: {
|
|
key,
|
|
name,
|
|
type,
|
|
value,
|
|
description,
|
|
isActive: true,
|
|
isSecret: false,
|
|
},
|
|
update: {
|
|
name,
|
|
type,
|
|
value,
|
|
description,
|
|
isActive: true,
|
|
isSecret: false,
|
|
},
|
|
});
|
|
}
|
|
|
|
listBuildJobs() {
|
|
return this.prisma.launcherBuildJob.findMany({
|
|
orderBy: { createdAt: 'desc' },
|
|
take: 30,
|
|
});
|
|
}
|
|
|
|
|
|
async listAppLinks() {
|
|
const [launcherRows, adminApps] = await Promise.all([
|
|
this.launcherPrisma.$queryRawUnsafe<LauncherAppRow[]>(`
|
|
SELECT app.id, app.app_key, app.display_name, app.description, app.subtitle, app.detail_url, app.video_path,
|
|
app.icon_key, app.launch_url, app.dev_app_path, app.exec_path, app.sort_order, app.is_enabled,
|
|
version.id AS version_id, version.latest_version, version.download_url
|
|
FROM launcher_apps app
|
|
LEFT JOIN launcher_app_versions version ON version.app_key = app.app_key
|
|
ORDER BY app.sort_order ASC, app.display_name ASC
|
|
`),
|
|
this.prisma.app.findMany({
|
|
orderBy: { name: 'asc' },
|
|
include: {
|
|
releases: {
|
|
orderBy: { createdAt: 'desc' },
|
|
take: 1,
|
|
},
|
|
},
|
|
}),
|
|
]);
|
|
|
|
const linkedKeys = new Set(
|
|
launcherRows.filter((row) => row.is_enabled).map((row) => row.app_key),
|
|
);
|
|
|
|
return {
|
|
linked: launcherRows.filter((row) => row.is_enabled).map(mapLauncherAppLinkRow),
|
|
unlinked: adminApps
|
|
.filter((app) => !linkedKeys.has(app.key))
|
|
.map((app) => ({
|
|
id: app.id,
|
|
key: app.key,
|
|
name: app.name,
|
|
description: app.description,
|
|
namespace: app.namespace,
|
|
status: app.status,
|
|
releases: app.releases,
|
|
})),
|
|
};
|
|
}
|
|
|
|
async listUserAppPermissions(userId: string) {
|
|
const rows = await this.launcherPrisma.$queryRawUnsafe<LauncherUserAppPermissionRow[]>(
|
|
`SELECT app.id AS app_id, app.app_key, app.display_name, app.description, app.sort_order, app.is_enabled,
|
|
permission.id AS permission_id, permission.can_view, permission.can_launch, permission.assigned_at
|
|
FROM launcher_apps app
|
|
LEFT JOIN launcher_user_apps permission
|
|
ON permission.app_id = app.id
|
|
AND permission.auth_user_id = $1::uuid
|
|
WHERE app.is_enabled = true
|
|
ORDER BY app.sort_order ASC, app.display_name ASC`,
|
|
userId,
|
|
);
|
|
|
|
return {
|
|
permissions: rows.map((row) => ({
|
|
appId: row.app_id,
|
|
appKey: row.app_key,
|
|
displayName: row.display_name,
|
|
description: row.description,
|
|
sortOrder: row.sort_order,
|
|
isEnabled: row.is_enabled,
|
|
permissionId: row.permission_id,
|
|
canView: row.can_view ?? false,
|
|
canLaunch: row.can_launch ?? false,
|
|
assignedAt: row.assigned_at,
|
|
})),
|
|
};
|
|
}
|
|
|
|
async updateUserAppPermissions(userId: string, body: UpdateLauncherUserAppPermissionsDto) {
|
|
const inputByAppKey = new Map(
|
|
body.permissions.map((permission) => [
|
|
permission.appKey,
|
|
{
|
|
canView: permission.canView,
|
|
canLaunch: permission.canView && permission.canLaunch,
|
|
},
|
|
]),
|
|
);
|
|
const apps = await this.launcherPrisma.$queryRawUnsafe<Array<{ id: string; app_key: string }>>(
|
|
`SELECT id, app_key FROM launcher_apps WHERE is_enabled = true`,
|
|
);
|
|
const appKeys = new Set(apps.map((app) => app.app_key));
|
|
const invalidKeys = [...inputByAppKey.keys()].filter((appKey) => !appKeys.has(appKey));
|
|
if (invalidKeys.length) {
|
|
throw new BadRequestException(`알 수 없는 앱 권한입니다: ${invalidKeys.join(', ')}`);
|
|
}
|
|
|
|
for (const app of apps) {
|
|
const input = inputByAppKey.get(app.app_key);
|
|
if (!input || !input.canView) {
|
|
await this.launcherPrisma.$executeRawUnsafe(
|
|
`DELETE FROM launcher_user_apps WHERE auth_user_id = $1::uuid AND app_id = $2::uuid`,
|
|
userId,
|
|
app.id,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
await this.launcherPrisma.$executeRawUnsafe(
|
|
`INSERT INTO launcher_user_apps (
|
|
auth_user_id, app_id, can_view, can_launch, assigned_at, created_at, updated_at
|
|
) VALUES (
|
|
$1::uuid, $2::uuid, $3, $4, now(), now(), now()
|
|
)
|
|
ON CONFLICT (auth_user_id, app_id) DO UPDATE SET
|
|
can_view = EXCLUDED.can_view,
|
|
can_launch = EXCLUDED.can_launch,
|
|
assigned_at = now(),
|
|
updated_at = now()`,
|
|
userId,
|
|
app.id,
|
|
input.canView,
|
|
input.canLaunch,
|
|
);
|
|
}
|
|
|
|
return this.listUserAppPermissions(userId);
|
|
}
|
|
|
|
async syncAdminAppsFromLauncher() {
|
|
const launcherRows = await this.launcherPrisma.$queryRawUnsafe<LauncherAppRow[]>(`
|
|
SELECT app.id, app.app_key, app.display_name, app.description, app.subtitle, app.detail_url, app.video_path,
|
|
app.icon_key, app.launch_url, app.dev_app_path, app.exec_path, app.sort_order, app.is_enabled,
|
|
version.id AS version_id, version.latest_version, version.download_url
|
|
FROM launcher_apps app
|
|
LEFT JOIN launcher_app_versions version ON version.app_key = app.app_key
|
|
ORDER BY app.sort_order ASC, app.display_name ASC
|
|
`);
|
|
|
|
const targetRows = launcherRows.filter((row) => row.app_key.toLowerCase() !== 'launcher');
|
|
const syncedApps = [];
|
|
const syncedReleases = [];
|
|
|
|
for (const row of targetRows) {
|
|
const appKey = normalizeLauncherAppKey(row.app_key);
|
|
const status = row.is_enabled ? 'ACTIVE' : 'DISABLED';
|
|
const existingByKey = await this.prisma.app.findUnique({ where: { key: appKey } });
|
|
const existingById = await this.prisma.app.findUnique({ where: { id: row.id }, include: { releases: true } });
|
|
|
|
let app;
|
|
if (existingByKey) {
|
|
app = await this.prisma.app.update({
|
|
where: { id: existingByKey.id },
|
|
data: { name: row.display_name, description: row.description, status },
|
|
});
|
|
|
|
if (existingById && existingById.id !== existingByKey.id) {
|
|
for (const release of existingById.releases) {
|
|
await this.prisma.appRelease.upsert({
|
|
where: { appId_version: { appId: app.id, version: release.version } },
|
|
create: {
|
|
appId: app.id,
|
|
version: release.version,
|
|
branch: release.branch,
|
|
commitSha: release.commitSha,
|
|
artifact: release.artifact,
|
|
imageTags: release.imageTags as any,
|
|
status: release.status,
|
|
},
|
|
update: {
|
|
artifact: release.artifact,
|
|
imageTags: release.imageTags as any,
|
|
status: release.status,
|
|
},
|
|
});
|
|
}
|
|
await this.prisma.app.delete({ where: { id: existingById.id } });
|
|
}
|
|
} else if (existingById) {
|
|
app = await this.prisma.app.update({
|
|
where: { id: existingById.id },
|
|
data: { key: appKey, name: row.display_name, description: row.description, status },
|
|
});
|
|
} else {
|
|
app = await this.prisma.app.create({
|
|
data: {
|
|
id: row.id,
|
|
key: appKey,
|
|
name: row.display_name,
|
|
description: row.description,
|
|
status,
|
|
},
|
|
});
|
|
}
|
|
syncedApps.push(app);
|
|
|
|
if (row.app_key !== appKey) {
|
|
const duplicate = await this.prisma.app.findUnique({
|
|
where: { key: row.app_key },
|
|
include: { releases: true },
|
|
});
|
|
|
|
if (duplicate && duplicate.id !== app.id) {
|
|
for (const release of duplicate.releases) {
|
|
await this.prisma.appRelease.upsert({
|
|
where: { appId_version: { appId: app.id, version: release.version } },
|
|
create: {
|
|
appId: app.id,
|
|
version: release.version,
|
|
branch: release.branch,
|
|
commitSha: release.commitSha,
|
|
artifact: release.artifact,
|
|
imageTags: release.imageTags as any,
|
|
status: release.status,
|
|
},
|
|
update: {
|
|
artifact: release.artifact,
|
|
imageTags: release.imageTags as any,
|
|
status: release.status,
|
|
},
|
|
});
|
|
}
|
|
await this.prisma.app.delete({ where: { id: duplicate.id } });
|
|
}
|
|
|
|
await this.launcherPrisma.$executeRawUnsafe(
|
|
`UPDATE launcher_app_versions SET app_key = $2, updated_at = now() WHERE app_key = $1`,
|
|
row.app_key,
|
|
appKey,
|
|
);
|
|
await this.launcherPrisma.$executeRawUnsafe(
|
|
`UPDATE launcher_apps SET app_key = $2, updated_at = now() WHERE app_key = $1`,
|
|
row.app_key,
|
|
appKey,
|
|
);
|
|
}
|
|
|
|
if (row.latest_version) {
|
|
const release = await this.prisma.appRelease.upsert({
|
|
where: { appId_version: { appId: app.id, version: row.latest_version } },
|
|
create: {
|
|
appId: app.id,
|
|
version: row.latest_version,
|
|
branch: 'release',
|
|
artifact: row.download_url,
|
|
status: 'SUCCEEDED',
|
|
},
|
|
update: {
|
|
artifact: row.download_url,
|
|
status: 'SUCCEEDED',
|
|
},
|
|
});
|
|
syncedReleases.push(release);
|
|
}
|
|
}
|
|
|
|
return { syncedApps: syncedApps.length, syncedReleases: syncedReleases.length };
|
|
}
|
|
|
|
async createAppLink(body: CreateLauncherAppLinkDto) {
|
|
const app = await this.prisma.app.findUnique({
|
|
where: { id: body.appId },
|
|
include: { releases: { orderBy: { createdAt: 'desc' }, take: 1 } },
|
|
});
|
|
if (!app) throw new NotFoundException('앱을 찾을 수 없습니다.');
|
|
|
|
const maxRows = await this.launcherPrisma.$queryRawUnsafe<{ max_order: number | null }[]>(
|
|
`SELECT MAX(sort_order) AS max_order FROM launcher_apps`,
|
|
);
|
|
const nextOrder = body.displayOrder ?? ((maxRows[0]?.max_order ?? 0) + 1);
|
|
const release = app.releases[0];
|
|
const displayName = body.displayName?.trim() || app.name;
|
|
const description = body.description === undefined ? (app.description || '') : body.description.trim();
|
|
const subtitle = body.subtitle === undefined ? (release?.version || null) : body.subtitle.trim();
|
|
const iconKey = body.iconKey?.trim() || app.key.toLowerCase().replace(/[^a-z0-9_-]/g, '-');
|
|
|
|
await this.launcherPrisma.$executeRawUnsafe(
|
|
`INSERT INTO launcher_apps (
|
|
id, app_key, display_name, description, subtitle, detail_url, video_path, icon_key,
|
|
launch_url, dev_app_path, exec_path, sort_order, is_enabled, created_at, updated_at
|
|
) VALUES (
|
|
$1::uuid, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, true, now(), now()
|
|
)
|
|
ON CONFLICT (app_key) DO UPDATE SET
|
|
display_name = EXCLUDED.display_name,
|
|
description = EXCLUDED.description,
|
|
subtitle = EXCLUDED.subtitle,
|
|
detail_url = COALESCE(EXCLUDED.detail_url, launcher_apps.detail_url),
|
|
video_path = COALESCE(EXCLUDED.video_path, launcher_apps.video_path),
|
|
icon_key = EXCLUDED.icon_key,
|
|
launch_url = COALESCE(EXCLUDED.launch_url, launcher_apps.launch_url),
|
|
dev_app_path = COALESCE(EXCLUDED.dev_app_path, launcher_apps.dev_app_path),
|
|
exec_path = COALESCE(EXCLUDED.exec_path, launcher_apps.exec_path),
|
|
sort_order = CASE WHEN launcher_apps.sort_order = 0 THEN EXCLUDED.sort_order ELSE launcher_apps.sort_order END,
|
|
is_enabled = true,
|
|
updated_at = now()`,
|
|
app.id,
|
|
app.key,
|
|
displayName,
|
|
description,
|
|
subtitle,
|
|
trimOptional(body.detailUrl),
|
|
trimOptional(body.videoPath),
|
|
iconKey,
|
|
trimOptional(body.launchUrl),
|
|
trimOptional(body.devAppPath),
|
|
trimOptional(body.execPath),
|
|
nextOrder,
|
|
);
|
|
|
|
if (release) {
|
|
await this.upsertLauncherAppVersion({
|
|
appKey: app.key,
|
|
displayName,
|
|
version: release.version,
|
|
artifact: body.artifact?.trim() || getReleaseClientArtifact(release),
|
|
});
|
|
}
|
|
|
|
const rows = await this.launcherPrisma.$queryRawUnsafe<LauncherAppRow[]>(
|
|
`SELECT app.id, app.app_key, app.display_name, app.description, app.subtitle, app.detail_url, app.video_path,
|
|
app.icon_key, app.launch_url, app.dev_app_path, app.exec_path, app.sort_order, app.is_enabled,
|
|
version.id AS version_id, version.latest_version, version.download_url
|
|
FROM launcher_apps app
|
|
LEFT JOIN launcher_app_versions version ON version.app_key = app.app_key
|
|
WHERE app.app_key = $1`,
|
|
app.key,
|
|
);
|
|
|
|
return { link: mapLauncherAppLinkRow(rows[0]) };
|
|
}
|
|
|
|
async updateAppLink(id: string, body: UpdateLauncherAppLinkDto) {
|
|
const rows = await this.launcherPrisma.$queryRawUnsafe<LauncherAppRow[]>(
|
|
`WITH updated AS (
|
|
UPDATE launcher_apps
|
|
SET sort_order = COALESCE($2, sort_order),
|
|
is_enabled = COALESCE($3, is_enabled),
|
|
display_name = COALESCE($4, display_name),
|
|
description = COALESCE($5, description),
|
|
subtitle = COALESCE($6, subtitle),
|
|
detail_url = COALESCE($7, detail_url),
|
|
video_path = COALESCE($8, video_path),
|
|
icon_key = COALESCE($9, icon_key),
|
|
launch_url = COALESCE($10, launch_url),
|
|
dev_app_path = COALESCE($11, dev_app_path),
|
|
exec_path = COALESCE($12, exec_path),
|
|
updated_at = now()
|
|
WHERE id = $1::uuid
|
|
RETURNING *
|
|
)
|
|
SELECT app.id, app.app_key, app.display_name, app.description, app.subtitle, app.detail_url, app.video_path,
|
|
app.icon_key, app.launch_url, app.dev_app_path, app.exec_path, app.sort_order, app.is_enabled,
|
|
version.id AS version_id, version.latest_version, version.download_url
|
|
FROM updated app
|
|
LEFT JOIN launcher_app_versions version ON version.app_key = app.app_key`,
|
|
id,
|
|
body.displayOrder ?? null,
|
|
body.isEnabled ?? null,
|
|
trimOptional(body.displayName),
|
|
trimOptional(body.description),
|
|
trimOptional(body.subtitle),
|
|
trimOptional(body.detailUrl),
|
|
trimOptional(body.videoPath),
|
|
trimOptional(body.iconKey),
|
|
trimOptional(body.launchUrl),
|
|
trimOptional(body.devAppPath),
|
|
trimOptional(body.execPath),
|
|
);
|
|
if (!rows[0]) throw new NotFoundException('앱 연결 정보를 찾을 수 없습니다.');
|
|
|
|
const app = await this.prisma.app.findFirst({
|
|
where: { key: rows[0].app_key },
|
|
include: {
|
|
releases: {
|
|
where: { status: 'SUCCEEDED' },
|
|
orderBy: { createdAt: 'desc' },
|
|
take: 1,
|
|
},
|
|
},
|
|
});
|
|
const release = app?.releases[0];
|
|
if (release) {
|
|
await this.upsertLauncherAppVersion({
|
|
appKey: rows[0].app_key,
|
|
displayName: rows[0].display_name,
|
|
version: release.version,
|
|
artifact: body.artifact?.trim() || getReleaseClientArtifact(release),
|
|
});
|
|
}
|
|
|
|
return { link: mapLauncherAppLinkRow(rows[0]) };
|
|
}
|
|
|
|
async publishAppReleaseToLauncher(releaseId: string) {
|
|
const release = await this.prisma.appRelease.findUnique({
|
|
where: { id: releaseId },
|
|
include: { app: true },
|
|
});
|
|
|
|
if (!release) throw new NotFoundException('릴리즈를 찾을 수 없습니다.');
|
|
if (release.status !== 'SUCCEEDED') {
|
|
throw new BadRequestException('성공한 릴리즈만 런처 앱 버전으로 반영할 수 있습니다.');
|
|
}
|
|
|
|
await this.upsertLauncherAppVersion({
|
|
appKey: release.app.key,
|
|
displayName: release.app.name,
|
|
version: release.version,
|
|
artifact: getReleaseClientArtifact(release),
|
|
});
|
|
}
|
|
|
|
private async upsertLauncherAppVersion(input: {
|
|
appKey: string;
|
|
displayName: string;
|
|
version: string;
|
|
artifact: string | null;
|
|
}) {
|
|
await this.launcherPrisma.$executeRawUnsafe(
|
|
`INSERT INTO launcher_app_versions (
|
|
id, app_key, display_name, latest_version, minimum_supported_version, download_url, release_notes, is_enabled, created_at, updated_at
|
|
) VALUES (
|
|
gen_random_uuid(), $1, $2, $3, NULL, $4, NULL, true, now(), now()
|
|
)
|
|
ON CONFLICT (app_key) DO UPDATE SET
|
|
display_name = EXCLUDED.display_name,
|
|
latest_version = EXCLUDED.latest_version,
|
|
download_url = COALESCE(EXCLUDED.download_url, launcher_app_versions.download_url),
|
|
is_enabled = true,
|
|
updated_at = now()`,
|
|
input.appKey,
|
|
input.displayName,
|
|
input.version,
|
|
input.artifact,
|
|
);
|
|
}
|
|
|
|
async moveAppLink(id: string, direction: 'up' | 'down') {
|
|
const rows = await this.launcherPrisma.$queryRawUnsafe<{ id: string; sort_order: number }[]>(
|
|
`SELECT id, sort_order FROM launcher_apps WHERE id = $1::uuid`,
|
|
id,
|
|
);
|
|
const current = rows[0];
|
|
if (!current) throw new NotFoundException('앱 연결 정보를 찾을 수 없습니다.');
|
|
|
|
const neighbors = await this.launcherPrisma.$queryRawUnsafe<{ id: string; sort_order: number }[]>(
|
|
direction === 'up'
|
|
? `SELECT id, sort_order FROM launcher_apps WHERE is_enabled = true AND sort_order < $1 ORDER BY sort_order DESC LIMIT 1`
|
|
: `SELECT id, sort_order FROM launcher_apps WHERE is_enabled = true AND sort_order > $1 ORDER BY sort_order ASC LIMIT 1`,
|
|
current.sort_order,
|
|
);
|
|
const neighbor = neighbors[0];
|
|
if (!neighbor) return this.listAppLinks();
|
|
|
|
await this.launcherPrisma.$transaction([
|
|
this.launcherPrisma.$executeRawUnsafe(`UPDATE launcher_apps SET sort_order = $2, updated_at = now() WHERE id = $1::uuid`, current.id, neighbor.sort_order),
|
|
this.launcherPrisma.$executeRawUnsafe(`UPDATE launcher_apps SET sort_order = $2, updated_at = now() WHERE id = $1::uuid`, neighbor.id, current.sort_order),
|
|
]);
|
|
|
|
return this.listAppLinks();
|
|
}
|
|
|
|
async deleteAppLink(id: string) {
|
|
await this.launcherPrisma.$executeRawUnsafe(
|
|
`UPDATE launcher_apps SET is_enabled = false, updated_at = now() WHERE id = $1::uuid`,
|
|
id,
|
|
);
|
|
return { deleted: true };
|
|
}
|
|
|
|
async createVersion(body: CreateLauncherVersionDto) {
|
|
const rows = await this.launcherPrisma.$queryRawUnsafe<LauncherVersionRow[]>(
|
|
`INSERT INTO launcher_app_versions (
|
|
id, app_key, display_name, latest_version, minimum_supported_version, download_url, release_notes, repository, branch, commit_sha, is_enabled, created_at, updated_at
|
|
) VALUES (
|
|
gen_random_uuid(), 'launcher', 'Autonomy Studio Launcher', $1, $2, $3, $4, $5, $6, $7, true, now(), now()
|
|
)
|
|
ON CONFLICT (app_key) DO UPDATE SET
|
|
latest_version = EXCLUDED.latest_version,
|
|
minimum_supported_version = EXCLUDED.minimum_supported_version,
|
|
download_url = EXCLUDED.download_url,
|
|
release_notes = EXCLUDED.release_notes,
|
|
repository = EXCLUDED.repository,
|
|
branch = EXCLUDED.branch,
|
|
commit_sha = EXCLUDED.commit_sha,
|
|
is_enabled = true,
|
|
updated_at = now()
|
|
RETURNING id, app_key, display_name, latest_version, minimum_supported_version, download_url, release_notes, repository, branch, commit_sha, is_enabled, created_at, updated_at`,
|
|
body.version.trim(),
|
|
body.minimumVersion?.trim() || null,
|
|
body.downloadUrl.trim(),
|
|
body.releaseNotes?.trim() || null,
|
|
body.repository ? normalizeRepository(body.repository) : null,
|
|
body.branch?.trim() || null,
|
|
body.commitSha?.trim() || null,
|
|
);
|
|
|
|
return { version: mapLauncherVersionRow(rows[0]) };
|
|
}
|
|
|
|
async createBuildJob(body: CreateLauncherBuildJobDto) {
|
|
const source = await this.getSourceSettings();
|
|
const target = body.target?.trim() || 'launcher-client';
|
|
const isBackend = target === 'launcher-backend';
|
|
if (isBackend && !body.artifact?.trim()) throw new BadRequestException('이미지 이름을 입력하세요.');
|
|
const requestedVersion = body.version?.trim() || 'package-json';
|
|
const requestedArtifact = body.artifact?.trim() || 'autonomy-studio-launcher.deb';
|
|
const repository = body.repository
|
|
? normalizeRepository(body.repository)
|
|
: isBackend
|
|
? source.backend.repository
|
|
: source.repository;
|
|
const branch = body.branch?.trim() || (isBackend ? source.backend.branch : source.branch) || 'release';
|
|
const commitSha = body.commitSha?.trim() || (isBackend ? source.backend.commitSha : source.commitSha) || null;
|
|
const plan = isBackend
|
|
? this.createLauncherBackendImagePlan(requestedVersion, requestedArtifact, body.uploadPath?.trim(), body.downloadUrl?.trim())
|
|
: this.createLauncherArtifactPlan(requestedVersion, branch, commitSha, requestedArtifact, body.uploadPath?.trim(), body.downloadUrl?.trim());
|
|
if (!repository) throw new BadRequestException('소스 저장소가 연결되지 않았습니다.');
|
|
if (!plan.uploadPath) throw new BadRequestException(isBackend ? 'Dockerfile 경로를 입력하세요.' : '업로드 경로를 입력하세요.');
|
|
if (!plan.downloadUrl) throw new BadRequestException(isBackend ? '이미지 태그를 입력하세요.' : '다운로드 URL을 입력하세요.');
|
|
|
|
const logs = [
|
|
`[queued] launcher build created: ${requestedArtifact}`,
|
|
`[target] ${target}`,
|
|
repository ? `[plan] clone ${this.toCloneUrl(repository)}` : '[plan] source repository is not linked',
|
|
`[plan] checkout branch ${branch}`,
|
|
commitSha ? `[plan] checkout commit ${commitSha}` : '[plan] use branch latest commit',
|
|
isBackend ? `[plan] build and push image ${plan.downloadUrl}` : `[plan] build deb package`,
|
|
isBackend ? `[plan] dockerfile ${plan.uploadPath}` : `[plan] upload ${requestedArtifact} to ${plan.uploadPath}`,
|
|
'[plan] final version will be read from package.json after clone',
|
|
isBackend ? `[plan] image tag ${plan.downloadUrl}` : `[plan] publish download url ${plan.downloadUrl}`,
|
|
'[pending] waiting for launcher build runner',
|
|
].join('\n');
|
|
|
|
const job = await this.prisma.launcherBuildJob.create({
|
|
data: {
|
|
target,
|
|
repository,
|
|
branch,
|
|
commitSha,
|
|
version: requestedVersion,
|
|
artifact: requestedArtifact,
|
|
uploadPath: plan.uploadPath,
|
|
downloadUrl: plan.downloadUrl,
|
|
logs,
|
|
},
|
|
});
|
|
|
|
void this.runBuildJob(job.id);
|
|
return { job };
|
|
}
|
|
|
|
async linkVersionSource(id: string, body: LinkLauncherVersionSourceDto) {
|
|
const rows = await this.launcherPrisma.$queryRawUnsafe<LauncherVersionRow[]>(
|
|
`UPDATE launcher_app_versions
|
|
SET repository = $2,
|
|
branch = $3,
|
|
commit_sha = $4,
|
|
updated_at = now()
|
|
WHERE id = $1::uuid
|
|
RETURNING id, app_key, display_name, latest_version, minimum_supported_version, download_url, release_notes, repository, branch, commit_sha, is_enabled, created_at, updated_at`,
|
|
id,
|
|
normalizeRepository(body.repository),
|
|
body.branch?.trim() || null,
|
|
body.commitSha?.trim() || null,
|
|
);
|
|
if (!rows[0]) throw new NotFoundException('런처 버전을 찾을 수 없습니다.');
|
|
return { version: mapLauncherVersionRow(rows[0]) };
|
|
}
|
|
|
|
private async runBuildJob(jobId: string) {
|
|
const job = await this.prisma.launcherBuildJob.findUnique({ where: { id: jobId } });
|
|
if (!job) return;
|
|
|
|
let logs = job.logs || '';
|
|
const append = async (...lines: string[]) => {
|
|
logs = [logs, ...lines].filter(Boolean).join('\n');
|
|
await this.prisma.launcherBuildJob.update({
|
|
where: { id: jobId },
|
|
data: { logs },
|
|
});
|
|
};
|
|
|
|
try {
|
|
if (!job.repository) throw new Error('런처 소스 저장소가 연결되지 않았습니다.');
|
|
|
|
await this.prisma.launcherBuildJob.update({
|
|
where: { id: jobId },
|
|
data: { status: 'RUNNING', startedAt: new Date() },
|
|
});
|
|
await append(`[running] launcher build runner picked job ${jobId}`);
|
|
|
|
const workRoot = process.env.LAUNCHER_BUILD_WORKDIR || '/tmp/studio-admin-launcher-builds';
|
|
const workDir = `${workRoot}/${jobId}`;
|
|
await rm(workDir, { recursive: true, force: true });
|
|
await mkdir(workRoot, { recursive: true });
|
|
|
|
const safeCloneUrl = this.toCloneUrl(job.repository);
|
|
const cloneUrl = await this.getAuthenticatedHttpCloneUrl(job.repository);
|
|
await runCommand(
|
|
`git clone --branch ${quote(job.branch)} ${quote(cloneUrl)} ${quote(workDir)}`,
|
|
process.cwd(),
|
|
append,
|
|
`git clone --branch ${quote(job.branch)} ${quote(safeCloneUrl)} ${quote(workDir)}`,
|
|
);
|
|
if (job.commitSha) {
|
|
await runCommand(`git checkout ${quote(job.commitSha)}`, workDir, append);
|
|
}
|
|
|
|
if (job.target === 'launcher-backend') {
|
|
const packageVersion = (await this.readPackageVersion(workDir)) || job.version;
|
|
const dockerfile = job.uploadPath || 'Dockerfile';
|
|
const imagePlan = this.createLauncherBackendImagePlan(packageVersion, job.artifact, dockerfile);
|
|
const imageTag = imagePlan.downloadUrl;
|
|
await append(`[backend] package version ${packageVersion}`);
|
|
await append(`[backend] dockerfile ${dockerfile}`);
|
|
await append(`[backend] image ${imageTag}`);
|
|
await this.prisma.launcherBuildJob.update({
|
|
where: { id: jobId },
|
|
data: { version: packageVersion, uploadPath: dockerfile, downloadUrl: imageTag },
|
|
});
|
|
await runCommand(
|
|
`docker build -t ${quote(imageTag)} -f ${quote(dockerfile)} .`,
|
|
workDir,
|
|
append,
|
|
);
|
|
await runCommand(`docker push ${quote(imageTag)}`, workDir, append);
|
|
await append('[done] launcher backend image build completed');
|
|
|
|
await this.prisma.launcherBuildJob.update({
|
|
where: { id: jobId },
|
|
data: {
|
|
status: 'SUCCEEDED',
|
|
finishedAt: new Date(),
|
|
version: packageVersion,
|
|
uploadPath: dockerfile,
|
|
downloadUrl: imageTag,
|
|
logs,
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
const buildCommand = process.env.LAUNCHER_BUILD_COMMAND || 'npm ci && npm run build:linux';
|
|
await runCommand(buildCommand, workDir, append);
|
|
|
|
const { stdout } = await runCommand(`find ${quote(workDir)} -type f -name '*.deb' | head -n 1`, workDir, append);
|
|
const debPath = stdout.trim().split('\n').filter(Boolean)[0];
|
|
if (!debPath) throw new Error('.deb 산출물을 찾지 못했습니다.');
|
|
|
|
const packageVersion = (await this.readPackageVersion(workDir)) || job.version;
|
|
const artifactName = basename(debPath);
|
|
const artifactPlan = this.createLauncherArtifactPlan(packageVersion, job.branch, job.commitSha, artifactName, job.uploadPath, job.downloadUrl);
|
|
const finalJob = {
|
|
version: packageVersion,
|
|
branch: job.branch,
|
|
commitSha: job.commitSha,
|
|
artifact: artifactName,
|
|
uploadPath: artifactPlan.uploadPath,
|
|
};
|
|
await append(
|
|
`[artifact] package version ${packageVersion}`,
|
|
`[artifact] found ${artifactName}`,
|
|
`[artifact] upload key ${artifactPlan.uploadPath}`,
|
|
);
|
|
await this.prisma.launcherBuildJob.update({
|
|
where: { id: jobId },
|
|
data: {
|
|
version: packageVersion,
|
|
artifact: artifactName,
|
|
uploadPath: artifactPlan.uploadPath,
|
|
downloadUrl: artifactPlan.downloadUrl,
|
|
},
|
|
});
|
|
|
|
const uploadedUrl = await this.uploadLauncherArtifactIfConfigured(finalJob, debPath, append);
|
|
if (!uploadedUrl) {
|
|
await mkdir(artifactPlan.uploadPath, { recursive: true });
|
|
const destination = `${artifactPlan.uploadPath.replace(/\/$/, '')}/${artifactName}`;
|
|
await copyFile(debPath, destination);
|
|
await append(`[upload] ${destination}`);
|
|
}
|
|
await append('[done] launcher build completed');
|
|
|
|
await this.prisma.launcherBuildJob.update({
|
|
where: { id: jobId },
|
|
data: {
|
|
status: 'SUCCEEDED',
|
|
finishedAt: new Date(),
|
|
version: packageVersion,
|
|
artifact: artifactName,
|
|
uploadPath: artifactPlan.uploadPath,
|
|
downloadUrl: uploadedUrl || artifactPlan.downloadUrl,
|
|
logs,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
await append(`[failed] ${message}`);
|
|
await this.prisma.launcherBuildJob.update({
|
|
where: { id: jobId },
|
|
data: { status: 'FAILED', finishedAt: new Date(), logs },
|
|
});
|
|
}
|
|
}
|
|
|
|
private createLauncherArtifactPlan(
|
|
version: string,
|
|
branch: string,
|
|
commitSha: string | null,
|
|
artifact: string,
|
|
uploadPath?: string,
|
|
downloadUrl?: string,
|
|
) {
|
|
const key = this.createLauncherArtifactObjectKey(version, branch, commitSha, artifact);
|
|
const publicBaseUrl = this.configService.get<string>('R2_PUBLIC_BASE_URL');
|
|
const r2Enabled = Boolean(
|
|
this.configService.get<string>('R2_BUCKET')
|
|
&& this.configService.get<string>('R2_ACCESS_KEY_ID')
|
|
&& this.configService.get<string>('R2_SECRET_ACCESS_KEY')
|
|
&& publicBaseUrl,
|
|
);
|
|
|
|
if (r2Enabled) {
|
|
return {
|
|
uploadPath: key,
|
|
downloadUrl: `${publicBaseUrl!.replace(/\/$/, '')}/${encodeObjectKey(key)}`,
|
|
};
|
|
}
|
|
|
|
return {
|
|
uploadPath: uploadPath ?? '',
|
|
downloadUrl: downloadUrl ?? '',
|
|
};
|
|
}
|
|
|
|
private createLauncherBackendImagePlan(
|
|
version: string,
|
|
artifact: string,
|
|
dockerfile?: string,
|
|
imageTag?: string,
|
|
) {
|
|
const registry = this.configService.get<string>('BUILD_REGISTRY', 'localhost:5000').replace(/\/$/, '');
|
|
const imageName = artifact.includes(':')
|
|
? artifact
|
|
: `${registry}/${artifact.replace(/^\/+/, '')}:${version}`;
|
|
|
|
return {
|
|
uploadPath: dockerfile || 'Dockerfile',
|
|
downloadUrl: imageTag || imageName,
|
|
};
|
|
}
|
|
|
|
private async uploadLauncherArtifactIfConfigured(
|
|
job: {
|
|
version: string;
|
|
branch: string;
|
|
commitSha: string | null;
|
|
artifact: string;
|
|
uploadPath: string;
|
|
},
|
|
artifactPath: string,
|
|
append: (...lines: string[]) => Promise<void>,
|
|
) {
|
|
const bucket = this.configService.get<string>('R2_BUCKET');
|
|
const publicBaseUrl = this.configService.get<string>('R2_PUBLIC_BASE_URL');
|
|
const accessKeyId = this.configService.get<string>('R2_ACCESS_KEY_ID');
|
|
const secretAccessKey = this.configService.get<string>('R2_SECRET_ACCESS_KEY');
|
|
|
|
if (!bucket || !publicBaseUrl || !accessKeyId || !secretAccessKey) {
|
|
return null;
|
|
}
|
|
|
|
const key = job.uploadPath || this.createLauncherArtifactObjectKey(job.version, job.branch, job.commitSha, job.artifact);
|
|
const fileStat = await stat(artifactPath);
|
|
const sha256 = await sha256File(artifactPath);
|
|
const client = this.getR2Client(accessKeyId, secretAccessKey);
|
|
|
|
await append(`[r2] upload ${key}`);
|
|
await client.send(new PutObjectCommand({
|
|
Bucket: bucket,
|
|
Key: key,
|
|
Body: createReadStream(artifactPath),
|
|
ContentLength: fileStat.size,
|
|
ContentType: 'application/vnd.debian.binary-package',
|
|
ContentDisposition: contentDispositionForFile(job.artifact),
|
|
Metadata: {
|
|
sha256,
|
|
target: 'launcher',
|
|
version: safeMetadataValue(job.version),
|
|
branch: safeMetadataValue(job.branch),
|
|
commit: safeMetadataValue(job.commitSha ?? ''),
|
|
},
|
|
}));
|
|
|
|
await client.send(new PutObjectCommand({
|
|
Bucket: bucket,
|
|
Key: `${key}.sha256`,
|
|
Body: `${sha256} ${job.artifact}\n`,
|
|
ContentType: 'text/plain; charset=utf-8',
|
|
}));
|
|
|
|
const latestDebName = 'autonomy-studio-launcher.deb';
|
|
const latestDebKey = this.createLauncherLatestObjectKey(latestDebName);
|
|
const installScriptKey = this.createLauncherLatestObjectKey('install.sh');
|
|
const latestDebUrl = `${publicBaseUrl.replace(/\/$/, '')}/${encodeObjectKey(latestDebKey)}`;
|
|
const installScript = createLauncherInstallScript(latestDebUrl);
|
|
|
|
await append(`[r2] update latest deb ${latestDebKey}`);
|
|
await client.send(new PutObjectCommand({
|
|
Bucket: bucket,
|
|
Key: latestDebKey,
|
|
Body: createReadStream(artifactPath),
|
|
ContentLength: fileStat.size,
|
|
ContentType: 'application/vnd.debian.binary-package',
|
|
ContentDisposition: contentDispositionForFile(latestDebName),
|
|
Metadata: {
|
|
sha256,
|
|
target: 'launcher',
|
|
version: safeMetadataValue(job.version),
|
|
branch: safeMetadataValue(job.branch),
|
|
commit: safeMetadataValue(job.commitSha ?? ''),
|
|
alias: 'latest',
|
|
},
|
|
}));
|
|
|
|
await append(`[r2] update install script ${installScriptKey}`);
|
|
await client.send(new PutObjectCommand({
|
|
Bucket: bucket,
|
|
Key: installScriptKey,
|
|
Body: installScript,
|
|
ContentType: 'text/x-shellscript; charset=utf-8',
|
|
ContentDisposition: contentDispositionForFile('install.sh'),
|
|
Metadata: {
|
|
target: 'launcher',
|
|
version: safeMetadataValue(job.version),
|
|
alias: 'latest',
|
|
},
|
|
}));
|
|
|
|
const url = `${publicBaseUrl.replace(/\/$/, '')}/${encodeObjectKey(key)}`;
|
|
await append(
|
|
`[r2] uploaded ${url}`,
|
|
`[r2] latest deb ${latestDebUrl}`,
|
|
`[r2] install command curl -fsSL ${publicBaseUrl.replace(/\/$/, '')}/${encodeObjectKey(installScriptKey)} | bash`,
|
|
`[r2] sha256 ${sha256}`,
|
|
);
|
|
return url;
|
|
}
|
|
|
|
private getR2Client(accessKeyId: string, secretAccessKey: string) {
|
|
if (this.r2Client) return this.r2Client;
|
|
|
|
const endpoint = this.configService.get<string>('R2_ENDPOINT')
|
|
|| `https://${this.configService.get<string>('R2_ACCOUNT_ID')}.r2.cloudflarestorage.com`;
|
|
|
|
this.r2Client = new S3Client({
|
|
region: this.configService.get<string>('R2_REGION', 'auto'),
|
|
endpoint,
|
|
credentials: {
|
|
accessKeyId,
|
|
secretAccessKey,
|
|
},
|
|
forcePathStyle: this.configService.get<string>('R2_FORCE_PATH_STYLE', 'true') !== 'false',
|
|
});
|
|
|
|
return this.r2Client;
|
|
}
|
|
|
|
private async readPackageVersion(workDir: string) {
|
|
try {
|
|
const packageJson = await readFile(`${workDir}/package.json`, 'utf8');
|
|
const parsed = JSON.parse(packageJson) as { version?: unknown };
|
|
return typeof parsed.version === 'string' && parsed.version.trim()
|
|
? parsed.version.trim()
|
|
: null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private createLauncherArtifactObjectKey(version: string, branch: string, commitSha: string | null, artifact: string) {
|
|
const prefix = this.configService.get<string>('R2_ARTIFACT_PREFIX', 'studio-artifacts');
|
|
const revision = commitSha?.slice(0, 7) || 'latest';
|
|
return [
|
|
prefix,
|
|
'launcher',
|
|
version,
|
|
branch,
|
|
revision,
|
|
artifact,
|
|
].map(sanitizeObjectKeyPart).join('/');
|
|
}
|
|
|
|
private createLauncherLatestObjectKey(fileName: string) {
|
|
const prefix = this.configService.get<string>('R2_ARTIFACT_PREFIX', 'studio-artifacts');
|
|
return [
|
|
prefix,
|
|
'launcher',
|
|
'latest',
|
|
fileName,
|
|
].map(sanitizeObjectKeyPart).join('/');
|
|
}
|
|
|
|
private toCloneUrl(repository: string) {
|
|
if (/^(https?:\/\/|ssh:\/\/|git@)/.test(repository)) return repository;
|
|
return `${this.configService
|
|
.get<string>('GITEA_BASE_URL', 'http://localhost:3000')
|
|
.replace(/\/$/, '')}/${repository}.git`;
|
|
}
|
|
|
|
private async getAuthenticatedHttpCloneUrl(repository: string) {
|
|
const baseUrl = this.toCloneUrl(repository);
|
|
const token = this.configService.get<string>('GITEA_ADMIN_TOKEN');
|
|
|
|
if (!token || this.configService.get<string>('BUILD_GIT_AUTH', 'token') === 'none') {
|
|
return baseUrl;
|
|
}
|
|
|
|
if (!/^https?:\/\//.test(baseUrl)) return baseUrl;
|
|
|
|
const username =
|
|
this.configService.get<string>('BUILD_GIT_USERNAME')
|
|
?? (await this.getGiteaUsername(token));
|
|
const url = new URL(baseUrl);
|
|
url.username = username;
|
|
url.password = token;
|
|
return url.toString();
|
|
}
|
|
|
|
private async getGiteaUsername(token: string) {
|
|
if (this.giteaUsername) return this.giteaUsername;
|
|
|
|
const response = await fetch(
|
|
`${this.configService
|
|
.get<string>('GITEA_BASE_URL', 'http://localhost:3000')
|
|
.replace(/\/$/, '')}/api/v1/user`,
|
|
{
|
|
headers: {
|
|
Accept: 'application/json',
|
|
Authorization: `token ${token}`,
|
|
},
|
|
},
|
|
);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Gitea 사용자 확인 실패: ${response.status}`);
|
|
}
|
|
|
|
const data = (await response.json()) as { login?: string };
|
|
if (!data.login) {
|
|
throw new Error('Gitea 토큰 사용자명을 확인할 수 없습니다.');
|
|
}
|
|
|
|
this.giteaUsername = data.login;
|
|
return data.login;
|
|
}
|
|
}
|
|
|
|
async function runCommand(
|
|
command: string,
|
|
cwd: string,
|
|
append: (...lines: string[]) => Promise<void>,
|
|
safeCommand = command,
|
|
) {
|
|
await append(`[run] ${safeCommand}`);
|
|
const result = await execAsync(command, {
|
|
cwd,
|
|
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
maxBuffer: 1024 * 1024 * 20,
|
|
shell: '/bin/bash',
|
|
});
|
|
const lines = [result.stdout, result.stderr]
|
|
.filter(Boolean)
|
|
.flatMap((output) => output.split('\n').filter(Boolean).map((line) => `[out] ${line}`));
|
|
if (lines.length) await append(...lines);
|
|
return result;
|
|
}
|
|
|
|
function trimOptional(value?: string) {
|
|
return value === undefined ? null : value.trim();
|
|
}
|
|
|
|
function normalizeLauncherAppKey(key: string) {
|
|
return key.trim().toLowerCase().replace(/\s+/g, '-');
|
|
}
|
|
|
|
function normalizeRepository(repository: string) {
|
|
return repository
|
|
.replace(/^https?:\/\/[^/]+\//, '')
|
|
.replace(/^ssh:\/\/git@[^/]+\//, '')
|
|
.replace(/\.git$/, '')
|
|
.replace(/^\/+/, '');
|
|
}
|
|
|
|
function quote(value: string) {
|
|
return `'${value.replace(/'/g, "'\''")}'`;
|
|
}
|
|
|
|
|
|
type LauncherVersionRow = {
|
|
id: string;
|
|
app_key: string;
|
|
display_name: string;
|
|
latest_version: string;
|
|
minimum_supported_version: string | null;
|
|
download_url: string | null;
|
|
release_notes: string | null;
|
|
repository: string | null;
|
|
branch: string | null;
|
|
commit_sha: string | null;
|
|
is_enabled: boolean;
|
|
created_at: Date;
|
|
updated_at: Date;
|
|
};
|
|
|
|
type LauncherAppRow = {
|
|
id: string;
|
|
app_key: string;
|
|
display_name: string;
|
|
description: string | null;
|
|
subtitle: string | null;
|
|
detail_url: string | null;
|
|
video_path: string | null;
|
|
icon_key: string | null;
|
|
launch_url: string | null;
|
|
dev_app_path: string | null;
|
|
exec_path: string | null;
|
|
sort_order: number;
|
|
is_enabled: boolean;
|
|
version_id: string | null;
|
|
latest_version: string | null;
|
|
download_url: string | null;
|
|
};
|
|
|
|
type LauncherUserAppPermissionRow = {
|
|
app_id: string;
|
|
app_key: string;
|
|
display_name: string;
|
|
description: string | null;
|
|
sort_order: number;
|
|
is_enabled: boolean;
|
|
permission_id: string | null;
|
|
can_view: boolean | null;
|
|
can_launch: boolean | null;
|
|
assigned_at: Date | null;
|
|
};
|
|
|
|
function mapLauncherVersionRow(row: LauncherVersionRow) {
|
|
return {
|
|
id: row.id,
|
|
version: row.latest_version,
|
|
minimumVersion: row.minimum_supported_version,
|
|
repository: row.repository,
|
|
branch: row.branch,
|
|
commitSha: row.commit_sha,
|
|
artifact: row.download_url ? row.download_url.split('/').pop() || row.download_url : '',
|
|
downloadUrl: row.download_url || '',
|
|
releaseNotes: row.release_notes,
|
|
isActive: row.is_enabled,
|
|
createdAt: row.created_at,
|
|
updatedAt: row.updated_at,
|
|
};
|
|
}
|
|
|
|
function mapLauncherAppLinkRow(row: LauncherAppRow) {
|
|
return {
|
|
id: row.id,
|
|
appId: row.id,
|
|
releaseId: row.version_id,
|
|
displayOrder: row.sort_order,
|
|
isEnabled: row.is_enabled,
|
|
displayName: row.display_name,
|
|
description: row.description,
|
|
subtitle: row.subtitle,
|
|
detailUrl: row.detail_url,
|
|
videoPath: row.video_path,
|
|
iconKey: row.icon_key,
|
|
launchUrl: row.launch_url,
|
|
devAppPath: row.dev_app_path,
|
|
execPath: row.exec_path,
|
|
artifact: row.download_url,
|
|
createdAt: new Date().toISOString(),
|
|
app: {
|
|
id: row.id,
|
|
key: row.app_key,
|
|
name: row.display_name,
|
|
description: row.description,
|
|
namespace: null,
|
|
status: row.is_enabled ? 'ACTIVE' : 'DISABLED',
|
|
},
|
|
release: row.latest_version ? {
|
|
id: row.version_id || row.id,
|
|
appId: row.id,
|
|
version: row.latest_version,
|
|
branch: 'release',
|
|
status: 'SUCCEEDED',
|
|
artifact: row.download_url,
|
|
imageTags: null,
|
|
createdAt: new Date().toISOString(),
|
|
app: {
|
|
id: row.id,
|
|
key: row.app_key,
|
|
name: row.display_name,
|
|
description: row.description,
|
|
namespace: null,
|
|
status: row.is_enabled ? 'ACTIVE' : 'DISABLED',
|
|
},
|
|
} : null,
|
|
};
|
|
}
|
|
|
|
function mapLauncherUnlinkedAppRow(row: LauncherAppRow) {
|
|
return {
|
|
id: row.id,
|
|
key: row.app_key,
|
|
name: row.display_name,
|
|
description: row.description,
|
|
namespace: null,
|
|
status: row.is_enabled ? 'ACTIVE' : 'DISABLED',
|
|
releases: row.latest_version ? [{
|
|
id: row.version_id || row.id,
|
|
appId: row.id,
|
|
version: row.latest_version,
|
|
branch: 'release',
|
|
status: 'SUCCEEDED',
|
|
artifact: row.download_url,
|
|
imageTags: null,
|
|
createdAt: new Date().toISOString(),
|
|
app: {
|
|
id: row.id,
|
|
key: row.app_key,
|
|
name: row.display_name,
|
|
description: row.description,
|
|
namespace: null,
|
|
status: row.is_enabled ? 'ACTIVE' : 'DISABLED',
|
|
},
|
|
}] : [],
|
|
};
|
|
}
|
|
|
|
function getReleaseClientArtifact(release: { artifact: string | null; imageTags?: unknown }) {
|
|
const imageTags = release.imageTags as { artifacts?: Record<string, string> } | null | undefined;
|
|
const artifacts = Object.values(imageTags?.artifacts ?? {}).filter(Boolean);
|
|
return artifacts[0] ?? release.artifact ?? null;
|
|
}
|
|
|
|
async function sha256File(filePath: string) {
|
|
const hash = createHash('sha256');
|
|
await new Promise<void>((resolve, reject) => {
|
|
const stream = createReadStream(filePath);
|
|
stream.on('data', (chunk) => hash.update(chunk));
|
|
stream.on('error', reject);
|
|
stream.on('end', resolve);
|
|
});
|
|
return hash.digest('hex');
|
|
}
|
|
|
|
function sanitizeObjectKeyPart(value: string) {
|
|
return value
|
|
.trim()
|
|
.replace(/^\/+|\/+$/g, '')
|
|
.replace(/[^a-zA-Z0-9._@+=-]+/g, '-')
|
|
|| 'unknown';
|
|
}
|
|
|
|
function encodeObjectKey(key: string) {
|
|
return key.split('/').map(encodeURIComponent).join('/');
|
|
}
|
|
|
|
function contentDispositionForFile(fileName: string) {
|
|
const fallbackName = fileName.replace(/[\\\"\r\n]/g, '_');
|
|
return `attachment; filename="${fallbackName}"; filename*=UTF-8''${encodeURIComponent(fileName)}`;
|
|
}
|
|
|
|
function createLauncherInstallScript(debUrl: string) {
|
|
return `#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
DEB_URL="${debUrl}"
|
|
DEB_PATH="/tmp/autonomy-studio-launcher.deb"
|
|
|
|
echo "Autonomy Studio Launcher 다운로드 중..."
|
|
if command -v curl >/dev/null 2>&1; then
|
|
curl -fL "$DEB_URL" -o "$DEB_PATH"
|
|
elif command -v wget >/dev/null 2>&1; then
|
|
wget -O "$DEB_PATH" "$DEB_URL"
|
|
else
|
|
echo "curl 또는 wget이 필요합니다." >&2
|
|
exit 1
|
|
fi
|
|
|
|
echo "Autonomy Studio Launcher 설치 중..."
|
|
sudo apt install -y "$DEB_PATH"
|
|
|
|
echo "설치가 완료되었습니다."
|
|
`;
|
|
}
|
|
|
|
function safeMetadataValue(value: string) {
|
|
return value.replace(/[^\x20-\x7E]/g, '').slice(0, 512);
|
|
}
|