forked from imbytecat/fullstack-starter
449 lines
12 KiB
TypeScript
449 lines
12 KiB
TypeScript
import * as path from 'node:path'
|
||
import { Schema } from '@effect/schema'
|
||
import { $ } from 'bun'
|
||
import { Console, Context, Data, Effect, Layer } from 'effect'
|
||
|
||
// ============================================================================
|
||
// Domain Models & Schema
|
||
// ============================================================================
|
||
|
||
/**
|
||
* Bun 构建目标后缀
|
||
*/
|
||
const BunTargetSuffixSchema = Schema.Literal(
|
||
'windows-x64',
|
||
'darwin-arm64',
|
||
'darwin-x64',
|
||
'linux-x64',
|
||
'linux-arm64',
|
||
)
|
||
|
||
/**
|
||
* Tauri sidecar 目标三元组
|
||
*/
|
||
const TauriTargetSchema = Schema.Literal(
|
||
'x86_64-pc-windows-msvc',
|
||
'aarch64-apple-darwin',
|
||
'x86_64-apple-darwin',
|
||
'x86_64-unknown-linux-gnu',
|
||
'aarch64-unknown-linux-gnu',
|
||
)
|
||
|
||
/**
|
||
* 目标映射配置
|
||
*/
|
||
const TargetMappingSchema = Schema.Struct({
|
||
bunSuffix: BunTargetSuffixSchema,
|
||
tauriTarget: TauriTargetSchema,
|
||
})
|
||
|
||
type TargetMapping = Schema.Schema.Type<typeof TargetMappingSchema>
|
||
|
||
/**
|
||
* 复制配置
|
||
*/
|
||
const CopyConfigSchema = Schema.Struct({
|
||
sourceDir: Schema.String.pipe(Schema.nonEmptyString()),
|
||
targetDir: Schema.String.pipe(Schema.nonEmptyString()),
|
||
baseName: Schema.String.pipe(Schema.nonEmptyString()),
|
||
mappings: Schema.Array(TargetMappingSchema).pipe(Schema.minItems(1)),
|
||
})
|
||
|
||
type CopyConfig = Schema.Schema.Type<typeof CopyConfigSchema>
|
||
|
||
/**
|
||
* 复制结果
|
||
*/
|
||
const CopyResultSchema = Schema.Struct({
|
||
bunSuffix: BunTargetSuffixSchema,
|
||
tauriTarget: TauriTargetSchema,
|
||
sourceFile: Schema.String,
|
||
targetFile: Schema.String,
|
||
success: Schema.Boolean,
|
||
})
|
||
|
||
type CopyResult = Schema.Schema.Type<typeof CopyResultSchema>
|
||
|
||
// ============================================================================
|
||
// Error Models
|
||
// ============================================================================
|
||
|
||
class ConfigError extends Data.TaggedError('ConfigError')<{
|
||
readonly message: string
|
||
readonly cause: unknown
|
||
}> {}
|
||
|
||
class FileSystemError extends Data.TaggedError('FileSystemError')<{
|
||
readonly operation: string
|
||
readonly path: string
|
||
readonly cause: unknown
|
||
}> {}
|
||
|
||
class CopyError extends Data.TaggedError('CopyError')<{
|
||
readonly source: string
|
||
readonly target: string
|
||
readonly cause: unknown
|
||
}> {}
|
||
|
||
// ============================================================================
|
||
// Services
|
||
// ============================================================================
|
||
|
||
/**
|
||
* 配置服务
|
||
*/
|
||
class CopyConfigService extends Context.Tag('CopyConfigService')<
|
||
CopyConfigService,
|
||
CopyConfig
|
||
>() {
|
||
/**
|
||
* 从原始数据创建并验证配置
|
||
*/
|
||
static fromRaw = (raw: unknown) =>
|
||
Effect.gen(function* () {
|
||
const decoded = yield* Schema.decodeUnknown(CopyConfigSchema)(raw)
|
||
return decoded
|
||
}).pipe(
|
||
Effect.catchAll((error) =>
|
||
Effect.fail(
|
||
new ConfigError({
|
||
message: '配置验证失败',
|
||
cause: error,
|
||
}),
|
||
),
|
||
),
|
||
)
|
||
|
||
/**
|
||
* 默认配置 Layer
|
||
*/
|
||
static readonly Live = Layer.effect(
|
||
CopyConfigService,
|
||
CopyConfigService.fromRaw({
|
||
sourceDir: path.join(__dirname, '..', 'server', 'out'),
|
||
targetDir: path.join(__dirname, 'src-tauri', 'binaries'),
|
||
baseName: 'server',
|
||
mappings: [
|
||
{
|
||
bunSuffix: 'windows-x64',
|
||
tauriTarget: 'x86_64-pc-windows-msvc',
|
||
},
|
||
{
|
||
bunSuffix: 'darwin-arm64',
|
||
tauriTarget: 'aarch64-apple-darwin',
|
||
},
|
||
{
|
||
bunSuffix: 'darwin-x64',
|
||
tauriTarget: 'x86_64-apple-darwin',
|
||
},
|
||
{
|
||
bunSuffix: 'linux-x64',
|
||
tauriTarget: 'x86_64-unknown-linux-gnu',
|
||
},
|
||
{
|
||
bunSuffix: 'linux-arm64',
|
||
tauriTarget: 'aarch64-unknown-linux-gnu',
|
||
},
|
||
],
|
||
} satisfies CopyConfig),
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 文件系统服务
|
||
*/
|
||
class FileSystemService extends Context.Tag('FileSystemService')<
|
||
FileSystemService,
|
||
{
|
||
readonly ensureDir: (dir: string) => Effect.Effect<void, FileSystemError>
|
||
readonly fileExists: (
|
||
filePath: string,
|
||
) => Effect.Effect<boolean, FileSystemError>
|
||
readonly copyFile: (
|
||
source: string,
|
||
target: string,
|
||
) => Effect.Effect<void, CopyError>
|
||
}
|
||
>() {
|
||
static readonly Live = Layer.succeed(FileSystemService, {
|
||
ensureDir: (dir: string) =>
|
||
Effect.tryPromise({
|
||
try: async () => {
|
||
await $`mkdir -p ${dir}`
|
||
},
|
||
catch: (cause: unknown) =>
|
||
new FileSystemError({
|
||
operation: 'ensureDir',
|
||
path: dir,
|
||
cause,
|
||
}),
|
||
}),
|
||
|
||
fileExists: (filePath: string) =>
|
||
Effect.tryPromise({
|
||
try: async () => {
|
||
const file = Bun.file(filePath)
|
||
return await file.exists()
|
||
},
|
||
catch: (cause: unknown) =>
|
||
new FileSystemError({
|
||
operation: 'fileExists',
|
||
path: filePath,
|
||
cause,
|
||
}),
|
||
}),
|
||
|
||
copyFile: (source: string, target: string) =>
|
||
Effect.tryPromise({
|
||
try: async () => {
|
||
await $`cp ${source} ${target}`
|
||
},
|
||
catch: (cause: unknown) =>
|
||
new CopyError({
|
||
source,
|
||
target,
|
||
cause,
|
||
}),
|
||
}),
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 复制服务
|
||
*/
|
||
class CopyService extends Context.Tag('CopyService')<
|
||
CopyService,
|
||
{
|
||
readonly copyBinary: (
|
||
config: CopyConfig,
|
||
mapping: TargetMapping,
|
||
) => Effect.Effect<CopyResult, CopyError | FileSystemError>
|
||
readonly copyAllBinaries: (
|
||
config: CopyConfig,
|
||
) => Effect.Effect<ReadonlyArray<CopyResult>, CopyError | FileSystemError>
|
||
}
|
||
>() {
|
||
static readonly Live = Layer.effect(
|
||
CopyService,
|
||
Effect.gen(function* () {
|
||
const fs = yield* FileSystemService
|
||
|
||
return {
|
||
copyBinary: (config: CopyConfig, mapping: TargetMapping) =>
|
||
Effect.gen(function* () {
|
||
const { sourceDir, targetDir, baseName } = config
|
||
const { bunSuffix, tauriTarget } = mapping
|
||
|
||
// 确定文件扩展名(Windows 需要 .exe)
|
||
const ext = tauriTarget.includes('windows') ? '.exe' : ''
|
||
|
||
// 构建源文件和目标文件路径
|
||
const sourceFile = path.join(
|
||
sourceDir,
|
||
`${baseName}-${bunSuffix}${ext}`,
|
||
)
|
||
const targetFile = path.join(
|
||
targetDir,
|
||
`${baseName}-${tauriTarget}${ext}`,
|
||
)
|
||
|
||
// 检查源文件是否存在
|
||
const exists = yield* fs.fileExists(sourceFile)
|
||
if (!exists) {
|
||
yield* Console.log(`⚠️ 跳过 ${bunSuffix}: 源文件不存在`)
|
||
return {
|
||
bunSuffix,
|
||
tauriTarget,
|
||
sourceFile,
|
||
targetFile,
|
||
success: false,
|
||
} satisfies CopyResult
|
||
}
|
||
|
||
// 复制文件
|
||
yield* fs.copyFile(sourceFile, targetFile)
|
||
|
||
yield* Console.log(`✓ ${bunSuffix} → ${tauriTarget}`)
|
||
yield* Console.log(` ${sourceFile}`)
|
||
yield* Console.log(` → ${targetFile}\n`)
|
||
|
||
return {
|
||
bunSuffix,
|
||
tauriTarget,
|
||
sourceFile,
|
||
targetFile,
|
||
success: true,
|
||
} satisfies CopyResult
|
||
}),
|
||
|
||
copyAllBinaries: (config: CopyConfig) =>
|
||
Effect.gen(function* () {
|
||
const effects = config.mappings.map((mapping) =>
|
||
Effect.gen(function* () {
|
||
const { sourceDir, targetDir, baseName } = config
|
||
const { bunSuffix, tauriTarget } = mapping
|
||
|
||
const ext = tauriTarget.includes('windows') ? '.exe' : ''
|
||
const sourceFile = path.join(
|
||
sourceDir,
|
||
`${baseName}-${bunSuffix}${ext}`,
|
||
)
|
||
const targetFile = path.join(
|
||
targetDir,
|
||
`${baseName}-${tauriTarget}${ext}`,
|
||
)
|
||
|
||
const exists = yield* fs.fileExists(sourceFile)
|
||
if (!exists) {
|
||
yield* Console.log(`⚠️ 跳过 ${bunSuffix}: 源文件不存在`)
|
||
return {
|
||
bunSuffix,
|
||
tauriTarget,
|
||
sourceFile,
|
||
targetFile,
|
||
success: false,
|
||
} satisfies CopyResult
|
||
}
|
||
|
||
yield* fs.copyFile(sourceFile, targetFile)
|
||
|
||
yield* Console.log(`✓ ${bunSuffix} → ${tauriTarget}`)
|
||
yield* Console.log(` ${sourceFile}`)
|
||
yield* Console.log(` → ${targetFile}\n`)
|
||
|
||
return {
|
||
bunSuffix,
|
||
tauriTarget,
|
||
sourceFile,
|
||
targetFile,
|
||
success: true,
|
||
} satisfies CopyResult
|
||
}),
|
||
)
|
||
|
||
return yield* Effect.all(effects, { concurrency: 'unbounded' })
|
||
}),
|
||
}
|
||
}),
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 报告服务
|
||
*/
|
||
class ReporterService extends Context.Tag('ReporterService')<
|
||
ReporterService,
|
||
{
|
||
readonly printSummary: (
|
||
results: ReadonlyArray<CopyResult>,
|
||
) => Effect.Effect<void>
|
||
}
|
||
>() {
|
||
static readonly Live = Layer.succeed(ReporterService, {
|
||
printSummary: (results: ReadonlyArray<CopyResult>) =>
|
||
Effect.gen(function* () {
|
||
const successful = results.filter((r) => r.success)
|
||
const failed = results.filter((r) => !r.success)
|
||
|
||
yield* Console.log('\n📦 复制摘要:')
|
||
yield* Console.log(` ✅ 成功: ${successful.length}`)
|
||
yield* Console.log(` ⚠️ 跳过: ${failed.length}`)
|
||
|
||
if (successful.length > 0) {
|
||
yield* Console.log('\n成功复制的文件:')
|
||
for (const result of successful) {
|
||
yield* Console.log(
|
||
` • ${result.bunSuffix} → ${result.tauriTarget}`,
|
||
)
|
||
}
|
||
}
|
||
|
||
if (failed.length > 0) {
|
||
yield* Console.log('\n跳过的文件:')
|
||
for (const result of failed) {
|
||
yield* Console.log(` • ${result.bunSuffix} (源文件不存在)`)
|
||
}
|
||
}
|
||
}),
|
||
})
|
||
}
|
||
|
||
// ============================================================================
|
||
// Main Program
|
||
// ============================================================================
|
||
|
||
const program = Effect.gen(function* () {
|
||
const config = yield* CopyConfigService
|
||
const fs = yield* FileSystemService
|
||
const copier = yield* CopyService
|
||
const reporter = yield* ReporterService
|
||
|
||
yield* Console.log('📦 开始复制二进制文件到 Tauri sidecar 目录...\n')
|
||
|
||
// 1. 检查源目录
|
||
const sourceExists = yield* fs.fileExists(config.sourceDir)
|
||
if (!sourceExists) {
|
||
yield* Console.error(`❌ 源目录不存在: ${config.sourceDir}`)
|
||
yield* Console.log(
|
||
'💡 提示: 请先在 apps/server 中运行 bun run compile 构建服务器二进制文件',
|
||
)
|
||
return yield* Effect.fail(
|
||
new FileSystemError({
|
||
operation: 'checkSourceDir',
|
||
path: config.sourceDir,
|
||
cause: '源目录不存在',
|
||
}),
|
||
)
|
||
}
|
||
|
||
// 2. 创建目标目录
|
||
yield* fs.ensureDir(config.targetDir)
|
||
yield* Console.log(`✓ 目标目录: ${config.targetDir}\n`)
|
||
|
||
// 3. 并行复制所有二进制文件
|
||
const results = yield* copier.copyAllBinaries(config)
|
||
|
||
// 4. 输出摘要
|
||
yield* reporter.printSummary(results)
|
||
|
||
return results
|
||
})
|
||
|
||
// ============================================================================
|
||
// Layer Composition
|
||
// ============================================================================
|
||
|
||
const MainLayer = Layer.mergeAll(
|
||
CopyConfigService.Live,
|
||
FileSystemService.Live,
|
||
CopyService.Live.pipe(Layer.provide(FileSystemService.Live)),
|
||
ReporterService.Live,
|
||
)
|
||
|
||
// ============================================================================
|
||
// Runner
|
||
// ============================================================================
|
||
|
||
const runnable = program.pipe(
|
||
Effect.provide(MainLayer),
|
||
Effect.catchTags({
|
||
ConfigError: (error) =>
|
||
Console.error(`❌ 配置错误: ${error.message}`, error.cause),
|
||
FileSystemError: (error) =>
|
||
Console.error(
|
||
`❌ 文件系统错误 [${error.operation}]: ${error.path}`,
|
||
error.cause,
|
||
),
|
||
CopyError: (error) =>
|
||
Console.error(
|
||
`❌ 复制失败: ${error.source} → ${error.target}`,
|
||
error.cause,
|
||
),
|
||
}),
|
||
Effect.tapErrorCause((cause) => Console.error('❌ 未预期的错误:', cause)),
|
||
)
|
||
|
||
Effect.runPromise(runnable).catch(() => {
|
||
process.exit(1)
|
||
})
|