Batch of red-team r3 fixes that were in the working tree before the 2026-09-28 design overhaul, committed as one unit with their tests. - desktop main: STT timeouts and sidecar, voice recording store, sync (credentials, audio, knowledge reindex, push gates), runtime provisioner, update policy, AltGr keybindings, voice-command policy, dictionary file codec/limits, meeting transcript condensing and a local recording ledger so interrupted-session recovery only closes meetings this device recorded (a phone's live meeting is left alone). - mobile: login CSRF via implicit token callbacks rejected, account deletion/retention, durable queue retention, knowledge realtime without unfiltered DELETE, meeting re-record failure paths, cloud STT client, preferences store/resync. - core: text chunking splits long unbroken transcripts to fit, template field policy, dictionary limits, meeting markdown inline handling. - server: payple webhook policy and cancellation order scope, meeting document generation quota, team RPC null-role guard, unified LLM quota in-flight accounting, knowledge chunk vector index, meeting re-record failure paths (migrations 20260929*). - ci: portable/runtime feed gates, update-policy schema, Forgejo file delete and alias planning. Four older tests are updated to the new contracts rather than the old behavior: token-pair auth callbacks are rejected, knowledge realtime no longer subscribes to DELETE, long transcript lines are split, and meeting recovery requires the local recording ledger for empty rows.
452 lines
No EOL
17 KiB
JavaScript
452 lines
No EOL
17 KiB
JavaScript
// scripts/ci/build-portable.mjs
|
|
// 서명 없이 배포할 수 있는 휴대용 Windows 배포본을 만든다(7z 분할 볼륨 + Scoop 매니페스트).
|
|
//
|
|
// 왜 분할인가: canonical feed(git.chanpaca.net)는 Cloudflare 뒤에 있고 업로드 본문이
|
|
// ~100MiB(104,857,600 bytes)를 넘으면 413으로 거부한다(실측: 60MiB 201 / 110MiB 413).
|
|
// 사이드카(faster-whisper)를 포함한 앱은 그 한도를 넘으므로, 95MiB 단위 7z 볼륨으로
|
|
// 나눠 올리고 Scoop이 볼륨을 이어서 해제하도록 한다(Scoop은 .7z.001 볼을 공식 지원).
|
|
//
|
|
// 산출물:
|
|
// apps/desktop/release/<version>/D3RO-Voice-<version>-x64-portable.7z.001/.002/...
|
|
// apps/desktop/release/<version>/portable.json (볼륨 인덱스: 이름/크기/sha256)
|
|
// bucket/d3ro-voice.json (Scoop 매니페스트, 커밋 대상)
|
|
//
|
|
// zip 분할 부품(수동 설치용)도 함께 만든다 — Windows 내장 Expand-Archive로 해제할 수 있어
|
|
// 사용자에게 7-Zip 설치를 요구하지 않는다. 7z 볼륨은 Scoop 전용으로 더 작다(162MiB vs 243MiB).
|
|
//
|
|
// 사용:
|
|
// npm run build --workspace=@d3ro/desktop
|
|
// node scripts/ci/build-portable.mjs # 7z 분할 볼륨
|
|
// node scripts/ci/build-portable.mjs --zip # + 로컬 배포용 단일 zip(게시용 아님)
|
|
|
|
import { spawnSync } from 'node:child_process'
|
|
import { createHash } from 'node:crypto'
|
|
import {
|
|
existsSync,
|
|
readFileSync,
|
|
readdirSync,
|
|
mkdirSync,
|
|
renameSync,
|
|
rmSync,
|
|
statSync,
|
|
writeFileSync,
|
|
} from 'node:fs'
|
|
import { createRequire } from 'node:module'
|
|
import { createGzip } from 'node:zlib'
|
|
import { pipeline } from 'node:stream/promises'
|
|
import { createReadStream, createWriteStream } from 'node:fs'
|
|
import * as tar from 'tar'
|
|
import { dirname, join } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { buildRuntimeIndex } from './lib/runtime-index-builder.mjs'
|
|
|
|
const require = createRequire(import.meta.url)
|
|
const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..')
|
|
const desktopDir = join(root, 'apps', 'desktop')
|
|
const version = JSON.parse(
|
|
readFileSync(join(root, 'release', 'product-version.json'), 'utf8'),
|
|
).version
|
|
|
|
const FEED = 'https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice'
|
|
const PORTABLE_VERSION_PATH = `${FEED}/portable-${version}`
|
|
const PORTABLE_LATEST_PATH = `${FEED}/portable-latest`
|
|
const ARCHIVE_BASE = `D3RO-Voice-${version}-x64-portable`
|
|
/** Cloudflare 본문 한도(100MiB)보다 여유를 둔 7z 볼륨 크기 (Scoop 경로) */
|
|
const VOLUME_SIZE = '95m'
|
|
const MAX_VOLUME_BYTES = 95 * 1024 * 1024
|
|
/** 수동 설치 스크립트용 zip 분할 부품 크기 */
|
|
const ZIP_PART_SIZE = '90m'
|
|
const MAX_ZIP_PART_BYTES = 95 * 1024 * 1024
|
|
|
|
const wantZip = process.argv.includes('--zip')
|
|
const releaseDir = join(desktopDir, 'release', version)
|
|
const appDir = join(releaseDir, 'win-unpacked')
|
|
|
|
if (!existsSync(join(desktopDir, 'out', 'main', 'index.js'))) {
|
|
console.error(
|
|
'out/main/index.js 가 없습니다. 먼저 데스크톱 번들을 빌드하세요:\n' +
|
|
' npm run build --workspace=@d3ro/desktop',
|
|
)
|
|
process.exit(1)
|
|
}
|
|
|
|
if (!existsSync(join(desktopDir, 'sidecar-dist', 'sidecar'))) {
|
|
console.error(
|
|
'STT 사이드카 번들이 없습니다. 서명 없이 배포해도 로컬 전사에는 사이드카가 필요합니다:\n' +
|
|
' npm --prefix apps/desktop run sidecar:setup\n' +
|
|
' npm --prefix apps/desktop run sidecar:build',
|
|
)
|
|
process.exit(1)
|
|
}
|
|
|
|
/** 현재 스크립트와 같은 Node로 CI 스크립트를 실행한다 */
|
|
function runNodeScript(relativePath, scriptArgs) {
|
|
console.log(`[portable] $ node ${relativePath} ${scriptArgs.join(' ')}`)
|
|
const result = spawnSync(
|
|
process.execPath,
|
|
[join(root, relativePath), ...scriptArgs],
|
|
{ cwd: root, stdio: 'inherit' },
|
|
)
|
|
if (result.status !== 0) {
|
|
console.error(`[portable] ${relativePath} 실패 (exit ${result.status ?? 'null'})`)
|
|
process.exit(result.status ?? 1)
|
|
}
|
|
}
|
|
|
|
function resolve7za() {
|
|
// electron-builder가 의존하는 7zip-bin이 플랫폼별 7za 실행 파일을 제공한다.
|
|
const platformDir =
|
|
process.platform === 'win32'
|
|
? join('win', process.arch === 'arm64' ? 'arm64' : 'x64')
|
|
: process.platform === 'darwin'
|
|
? join('mac', process.arch === 'arm64' ? 'arm64' : 'x64')
|
|
: join('linux', process.arch === 'arm64' ? 'arm64' : 'x64')
|
|
const binary = process.platform === 'win32' ? '7za.exe' : '7za'
|
|
const candidate = join(root, 'node_modules', '7zip-bin', platformDir, binary)
|
|
if (!existsSync(candidate)) {
|
|
console.error(`7za를 찾을 수 없습니다: ${candidate}\n npm ci 후 다시 실행하세요.`)
|
|
process.exit(1)
|
|
}
|
|
return candidate
|
|
}
|
|
|
|
const target = process.argv.includes('--zip') ? 'zip' : 'dir'
|
|
console.log(
|
|
`[portable] electron-builder 빌드 (version ${version}, target=${target}, 서명 없음)`,
|
|
)
|
|
const targetArgs = target === 'zip' ? ['--win', 'zip'] : ['--win', 'dir']
|
|
const build = spawnSync(
|
|
'npx',
|
|
[
|
|
'electron-builder',
|
|
...targetArgs,
|
|
'--x64',
|
|
'--config',
|
|
'electron-builder.yml',
|
|
'--publish',
|
|
'never',
|
|
// 서명이 없으므로 NSIS 경로의 fail-closed 게이트를 이 채널에서만 명시적으로 해제한다.
|
|
// (자동 업데이트 피드가 아니라 별도 portable 경로로만 게시한다 — publish 스크립트 참조)
|
|
'-c.win.forceCodeSigning=false',
|
|
'-c.npmRebuild=false',
|
|
],
|
|
{ cwd: desktopDir, stdio: 'inherit', shell: process.platform === 'win32' },
|
|
)
|
|
|
|
if (build.status !== 0) {
|
|
console.error(`[portable] electron-builder 실패 (exit ${build.status ?? 'null'})`)
|
|
process.exit(build.status ?? 1)
|
|
}
|
|
|
|
if (!existsSync(appDir)) {
|
|
console.error(`[portable] win-unpacked가 없습니다: ${appDir}`)
|
|
process.exit(1)
|
|
}
|
|
|
|
// 네이티브 모듈 ABI 사고 방지: 호스트 Node ABI로 빌드된 모듈이 섞이면 설치본이 시작조차 못 한다.
|
|
// (실측 사고: better_sqlite3.node가 NODE_MODULE_VERSION 131 → Electron 130 요구)
|
|
runNodeScript('scripts/ci/fix-native-abi.mjs', ['--dir', appDir])
|
|
runNodeScript('scripts/ci/verify-native-abi.mjs', ['--dir', appDir])
|
|
// electron-builder의 --dir/--prepackaged 경로는 app-update.yml을 만들지 않는다.
|
|
// 이 파일이 없으면 electron-updater가 설정을 읽지 못해 자동 업데이트가 죽는다(실측).
|
|
runNodeScript('scripts/ci/write-app-update-yml.mjs', ['--dir', appDir])
|
|
|
|
// 휴대용 표식: 이 트리(Scoop 7z · 수동 설치 zip)는 NSIS 설치자가 소유하지 않는다.
|
|
// 런타임(src/main/install-kind.ts)은 NSIS 제거 프로그램이 없는 설치본에서 내장 자동 업데이트(NsisUpdater)를
|
|
// 끈다 — 켜 두면 NSIS 설치자가 다른 경로에 두 번째 사본을 깔고, 실행 중인 사본은 옛 버전으로 남는다.
|
|
// 표식은 그 판단을 명시하고 로그 사유를 남긴다. 파일 이름은 install-kind.ts 가 정본이다.
|
|
const installKindSource = readFileSync(join(desktopDir, 'src', 'main', 'install-kind.ts'), 'utf8')
|
|
const markerMatch = installKindSource.match(/export const PORTABLE_INSTALL_MARKER\s*=\s*'([^']+)'/)
|
|
if (!markerMatch) {
|
|
console.error('[portable] install-kind.ts에서 PORTABLE_INSTALL_MARKER를 찾을 수 없습니다.')
|
|
process.exit(1)
|
|
}
|
|
const portableMarkerPath = join(appDir, 'resources', markerMatch[1])
|
|
writeFileSync(
|
|
portableMarkerPath,
|
|
`${JSON.stringify({ kind: 'portable', version, updater: 'scoop-or-install-script' }, null, 2)}\n`,
|
|
'utf8',
|
|
)
|
|
// 같은 win-unpacked 트리로 나중에 NSIS 설치자(--prepackaged)를 만들 수 있으므로, 아카이브를 만든 뒤에는
|
|
// 표식을 지운다. (런타임은 NSIS 제거 프로그램을 먼저 보므로 표식이 섞여도 설치본 업데이트는 꺼지지 않는다.)
|
|
process.on('exit', () => {
|
|
rmSync(portableMarkerPath, { force: true })
|
|
})
|
|
|
|
// 이전 볼륨 정리 (같은 버전 재드 시 잔여 볼륨이 섞이지 않도록)
|
|
for (const name of readdirSync(releaseDir)) {
|
|
if (name.startsWith(ARCHIVE_BASE)) {
|
|
rmSync(join(releaseDir, name), { force: true })
|
|
}
|
|
}
|
|
|
|
const sevenZip = resolve7za()
|
|
const archivePath = join(releaseDir, `${ARCHIVE_BASE}.7z`)
|
|
|
|
console.log(`[portable] 7z 분할 볼륨 생성 (볼륨 ${VOLUME_SIZE})`)
|
|
const compress = spawnSync(
|
|
sevenZip,
|
|
[
|
|
'a',
|
|
'-t7z',
|
|
'-m0=lzma2',
|
|
'-mx=9',
|
|
'-mmt=on',
|
|
'-ms=on',
|
|
`-v${VOLUME_SIZE}`,
|
|
'-bsp0',
|
|
'-bso0',
|
|
'-y',
|
|
archivePath,
|
|
join(appDir, '*'),
|
|
],
|
|
{ stdio: 'inherit' },
|
|
)
|
|
if (compress.status !== 0) {
|
|
console.error(`[portable] 7z 압축 실패 (exit ${compress.status ?? 'null'})`)
|
|
process.exit(compress.status ?? 1)
|
|
}
|
|
|
|
const volumes = readdirSync(releaseDir)
|
|
.filter((name) => name.startsWith(`${ARCHIVE_BASE}.7z.`))
|
|
.sort()
|
|
|
|
if (volumes.length === 0) {
|
|
console.error('[portable] 7z 볼륨을 찾을 수 없습니다.')
|
|
process.exit(1)
|
|
}
|
|
|
|
const volumeEntries = []
|
|
for (const name of volumes) {
|
|
const path = join(releaseDir, name)
|
|
const size = statSync(path).size
|
|
if (size > MAX_VOLUME_BYTES) {
|
|
console.error(
|
|
`[portable] 볼륨이 너무 큽니다(${name}: ${(size / 1048576).toFixed(1)}MiB). ` +
|
|
'VOLUME_SIZE를 줄이세요 — Cloudflare가 100MiB 초과 업로드를 413으로 거부합니다.',
|
|
)
|
|
process.exit(1)
|
|
}
|
|
const sha256 = createHash('sha256').update(readFileSync(path)).digest('hex')
|
|
volumeEntries.push({ name, size, sha256, url: `${PORTABLE_VERSION_PATH}/${name}` })
|
|
}
|
|
|
|
const totalBytes = volumeEntries.reduce((sum, entry) => sum + entry.size, 0)
|
|
|
|
const portableIndex = {
|
|
channel: 'portable-unsigned',
|
|
version,
|
|
archive: `${ARCHIVE_BASE}.7z`,
|
|
volumes: volumeEntries,
|
|
volumeCount: volumeEntries.length,
|
|
totalSize: totalBytes,
|
|
releasedAt: new Date().toISOString(),
|
|
latestIndexUrl: `${PORTABLE_LATEST_PATH}/portable.json`,
|
|
installScriptUrl: `${PORTABLE_LATEST_PATH}/install-d3ro-voice.ps1`,
|
|
notes: [
|
|
'서명 없는 휴대용 배포본입니다. 자동 업데이트 피드(latest.yml)는 갱신하지 않습니다.',
|
|
'Cloudflare 업로드 한도(100MiB) 때문에 7z 볼륨으로 나뉘어 있습니다. Scoop이 이어서 해제합니다.',
|
|
'수동 설치: install-d3ro-voice.ps1 (7-Zip 필요) 또는 Scoop 사용을 권장합니다.',
|
|
],
|
|
}
|
|
|
|
writeFileSync(
|
|
join(releaseDir, 'portable.json'),
|
|
`${JSON.stringify(portableIndex, null, 2)}\n`,
|
|
'utf8',
|
|
)
|
|
|
|
const scoopManifest = {
|
|
version,
|
|
description: '로컬 AI 음성 어시스턴트 (faster-whisper + Ollama, 100% 오프라인 지원)',
|
|
homepage: 'https://d3ro.chanpaca.net',
|
|
license: 'MIT',
|
|
architecture: {
|
|
'64bit': {
|
|
url: volumeEntries.map((entry) => entry.url),
|
|
hash: volumeEntries.map((entry) => entry.sha256),
|
|
},
|
|
},
|
|
shortcuts: [['D3RO Voice.exe', 'D3RO Voice']],
|
|
checkver: {
|
|
url: `${PORTABLE_LATEST_PATH}/portable.json`,
|
|
jsonpath: '$.version',
|
|
},
|
|
autoupdate: {
|
|
architecture: {
|
|
'64bit': {
|
|
url: volumeEntries.map((entry) =>
|
|
entry.url.replace(`portable-${version}`, 'portable-$version'),
|
|
),
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
writeFileSync(
|
|
join(root, 'bucket', 'd3ro-voice.json'),
|
|
`${JSON.stringify(scoopManifest, null, 2)}\n`,
|
|
'utf8',
|
|
)
|
|
|
|
// ── 수동 설치용 zip 분할 부품 ─────────────────────────────
|
|
const zipTarget = join(releaseDir, `${ARCHIVE_BASE}.zip`)
|
|
console.log(`[portable] 수동 설치용 zip 생성 (분할 ${ZIP_PART_SIZE})`)
|
|
const zipBuild = spawnSync(
|
|
'npx',
|
|
[
|
|
'electron-builder',
|
|
// 검증된 트리에서 바로 패키징한다(ne ABI가 확실한 디렉토리만 사용).
|
|
'--prepackaged',
|
|
appDir,
|
|
'--win',
|
|
'zip',
|
|
'--x64',
|
|
'--config',
|
|
'electron-builder.yml',
|
|
'--publish',
|
|
'never',
|
|
'-c.win.forceCodeSigning=false',
|
|
'-c.npmRebuild=false',
|
|
],
|
|
{ cwd: desktopDir, stdio: 'inherit', shell: process.platform === 'win32' },
|
|
)
|
|
if (zipBuild.status !== 0) {
|
|
console.error(`[portable] zip 빌드 실패 (exit ${zipBuild.status ?? 'null'})`)
|
|
process.exit(zipBuild.status ?? 1)
|
|
}
|
|
|
|
const producedZip = readdirSync(releaseDir).find((name) =>
|
|
name.endsWith('.zip') && name.includes(version) && !name.includes('.part'),
|
|
)
|
|
if (!producedZip) {
|
|
console.error('[portable] zip 산출물을 찾을 수 없습니다.')
|
|
process.exit(1)
|
|
}
|
|
if (join(releaseDir, producedZip) !== zipTarget) {
|
|
renameSync(join(releaseDir, producedZip), zipTarget)
|
|
}
|
|
|
|
// zip을 90MiB 단위로 바이트 분할한다 (사용자가 이어 붙여 Expand-Archive로 해제)
|
|
const zipBytes = readFileSync(zipTarget)
|
|
const partSize = 90 * 1024 * 1024
|
|
const zipParts = []
|
|
for (let offset = 0, index = 1; offset < zipBytes.length; offset += partSize, index += 1) {
|
|
const slice = zipBytes.subarray(offset, Math.min(offset + partSize, zipBytes.length))
|
|
const name = `${ARCHIVE_BASE}.zip.${String(index).padStart(3, '0')}`
|
|
if (slice.length > MAX_ZIP_PART_BYTES) {
|
|
console.error(`[portable] zip 부품이 너무 큽니다: ${name}`)
|
|
process.exit(1)
|
|
}
|
|
writeFileSync(join(releaseDir, name), slice)
|
|
zipParts.push({
|
|
name,
|
|
size: slice.length,
|
|
sha256: createHash('sha256').update(slice).digest('hex'),
|
|
url: `${PORTABLE_VERSION_PATH}/${name}`,
|
|
})
|
|
}
|
|
const zipSha256 = createHash('sha256').update(zipBytes).digest('hex')
|
|
|
|
// 인덱스에 zip 부품 정보를 추가한다 (설치 스크립트가 사용)
|
|
const indexJson = JSON.parse(readFileSync(join(releaseDir, 'portable.json'), 'utf8'))
|
|
indexJson.zipArchive = `${ARCHIVE_BASE}.zip`
|
|
indexJson.zipSize = zipBytes.length
|
|
indexJson.zipSha256 = zipSha256
|
|
indexJson.zipParts = zipParts
|
|
writeFileSync(
|
|
join(releaseDir, 'portable.json'),
|
|
`${JSON.stringify(indexJson, null, 2)}\n`,
|
|
'utf8',
|
|
)
|
|
// ── 로컬 AI 런타임 번들 (설치본에 넣지 않고 처음 필요할 때 내려받는다) ──────
|
|
const RUNTIME_DIR = join(releaseDir, 'runtime')
|
|
rmSync(RUNTIME_DIR, { recursive: true, force: true })
|
|
mkdirSync(RUNTIME_DIR, { recursive: true })
|
|
|
|
/** 디렉터리를 tar.gz으로 묶어 90MiB 부품으로 나누고 인덱스 항목을 돌려준다 */
|
|
async function packRuntime(component, sourceDir, archiveBase) {
|
|
const archivePath = join(RUNTIME_DIR, `${archiveBase}.tar.gz`)
|
|
await pipeline(
|
|
tar.c({ cwd: sourceDir, portable: true, gzip: false }, ['.']),
|
|
createGzip({ level: 6 }),
|
|
createWriteStream(archivePath),
|
|
)
|
|
|
|
const archiveBytes = readFileSync(archivePath)
|
|
const sha256 = createHash('sha256').update(archiveBytes).digest('hex')
|
|
const partSize = 90 * 1024 * 1024
|
|
const parts = []
|
|
for (let offset = 0, index = 1; offset < archiveBytes.length; offset += partSize, index += 1) {
|
|
const slice = archiveBytes.subarray(offset, Math.min(offset + partSize, archiveBytes.length))
|
|
const name = `${archiveBase}.tar.gz.${String(index).padStart(3, '0')}`
|
|
if (slice.length > MAX_ZIP_PART_BYTES) {
|
|
throw new Error(`런타임 부품이 너무 큽니다: ${name}`)
|
|
}
|
|
writeFileSync(join(RUNTIME_DIR, name), slice)
|
|
parts.push({
|
|
name,
|
|
size: slice.length,
|
|
sha256: createHash('sha256').update(slice).digest('hex'),
|
|
})
|
|
}
|
|
|
|
return {
|
|
component,
|
|
archive: `${archiveBase}.tar.gz`,
|
|
sha256,
|
|
totalSize: archiveBytes.length,
|
|
parts,
|
|
}
|
|
}
|
|
const runtimeComponents = {}
|
|
const sidecarSource = join(desktopDir, 'sidecar-dist', 'sidecar')
|
|
// @ffmpeg-installer가 플랫폼별로 제공하는 실행 파일 경로를 그대로 사용한다
|
|
const ffmpegInstaller = (() => {
|
|
try {
|
|
return require('@ffmpeg-installer/ffmpeg')
|
|
} catch {
|
|
return null
|
|
}
|
|
})()
|
|
const ffmpegSource = ffmpegInstaller?.path ? dirname(ffmpegInstaller.path) : null
|
|
|
|
if (existsSync(sidecarSource)) {
|
|
console.log('[portable] 런타임 번들 생성: sidecar (faster-whisper 진)')
|
|
runtimeComponents.sidecar = await packRuntime('sidecar', sidecarSource, 'd3ro-runtime-sidecar')
|
|
} else {
|
|
console.error('[portable] 경고: sidecar-dist가 없어 런타임 번들을 만들 수 없습니다')
|
|
}
|
|
|
|
if (ffmpegSource && existsSync(ffmpegSource)) {
|
|
console.log('[portable] 런타임 번들 생성: ffmpeg')
|
|
runtimeComponents.ffmpeg = await packRuntime('ffmpeg', ffmpegSource, 'd3ro-runtime-ffmpeg')
|
|
} else {
|
|
console.error('[portable] 경고: @ffmpeg-installer가 없어 ffmpeg 런타임을 만들 수 없습니다')
|
|
}
|
|
|
|
// 사이드카(PyInstaller)와 @ffmpeg-installer 바이너리는 이 호스트의 네이티브 실행 파일이다.
|
|
// platform/arch 를 인덱스에 밝혀야 다른 플랫폼 앱이 부품(~160MB)을 받기 전에 거부할 수 있다.
|
|
const runtimeIndex = buildRuntimeIndex({
|
|
version,
|
|
generatedAt: new Date().toISOString(),
|
|
platform: process.platform,
|
|
arch: process.arch,
|
|
partBaseUrl: `${FEED}/runtime-${version}`,
|
|
components: runtimeComponents,
|
|
})
|
|
writeFileSync(join(RUNTIME_DIR, 'runtime.json'), `${JSON.stringify(runtimeIndex, null, 2)}\n`, 'utf8')
|
|
console.log(
|
|
[
|
|
'[portable] 완료',
|
|
` 볼륨 : ${volumeEntries.length}개 / 합계 ${(totalBytes / 1048576).toFixed(1)}MiB`,
|
|
...volumeEntries.map(
|
|
(entry) => ` ${entry.name} (${(entry.size / 1048576).toFixed(1)}MiB)`,
|
|
),
|
|
` zip : ${zipParts.length}개 부품 / 합계 ${(zipBytes.length / 1048576).toFixed(1)}MiB`,
|
|
` 인덱스 : ${join(releaseDir, 'portable.json')}`,
|
|
` scoop : ${join(root, 'bucket', 'd3ro-voice.json')}`,
|
|
` 런타임 : ${Object.keys(runtimeComponents).join(', ') || '(없음)'} (${runtimeIndex.platform}-${runtimeIndex.arch}) → ${join(RUNTIME_DIR, 'runtime.json')}`,
|
|
` 게시 : node scripts/ci/publish-portable-release.mjs`,
|
|
].join('\n'),
|
|
) |