let PrismaClient try { ;({ PrismaClient } = require('@prisma/client')) } catch { ;({ PrismaClient } = require('/app/node_modules/@prisma/client')) } const fs = require('fs') const path = require('path') const { spawnSync } = require('child_process') const { resolveStorageLocationName } = require('./locationNormalization') const OUTPUT_ROOT = path.resolve(__dirname, '../../temp_migration_output') const PLINK_PATH = process.env.FIXED_ASSET_PLINK_PATH || 'C:\\Program Files\\PuTTY\\plink.exe' const SSH_HOST = process.env.FIXED_ASSET_SSH_HOST || 'root@1.13.22.133' const SSH_HOSTKEY = process.env.FIXED_ASSET_SSH_HOSTKEY || 'SHA256:huTZZAYXh4yd5AvE4jo7drXaF/WSFF0SkzYHoddbMLA' const SSH_PASSWORD = process.env.FIXED_ASSET_SSH_PASSWORD || '' const REMOTE_NODE_COMMAND = 'docker exec -i equipment-backend node' const normalizeText = (value) => String(value == null ? '' : value) .replace(/\r/g, ' ') .replace(/\n/g, ' ') .replace(/\s+/g, ' ') .trim() const parseArgs = () => { const args = process.argv.slice(2) const options = { inputDir: '', keepExisting: false, includeOwnerless: false } for (let index = 0; index < args.length; index += 1) { const arg = args[index] if (arg === '--input-dir') { options.inputDir = args[index + 1] || '' index += 1 } else if (arg === '--keep-existing') { options.keepExisting = true } else if (arg === '--include-ownerless') { options.includeOwnerless = true } } return options } const ensureOutputDir = (prefix) => { const timestamp = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19) const outputDir = path.join(OUTPUT_ROOT, `${prefix}_${timestamp}`) fs.mkdirSync(outputDir, { recursive: true }) return outputDir } const resolveLatestMigrationDir = () => { const candidates = fs .readdirSync(OUTPUT_ROOT, { withFileTypes: true }) .filter((entry) => entry.isDirectory() && entry.name.startsWith('migration_dry_run_')) .map((entry) => entry.name) .sort() if (!candidates.length) { throw new Error(`未找到 dry-run 输出目录: ${OUTPUT_ROOT}`) } return path.join(OUTPUT_ROOT, candidates[candidates.length - 1]) } const resolveInputDir = (inputDirArg) => { if (!inputDirArg) { return resolveLatestMigrationDir() } const resolved = path.isAbsolute(inputDirArg) ? inputDirArg : path.resolve(process.cwd(), inputDirArg) if (!fs.existsSync(resolved)) { throw new Error(`指定的输入目录不存在: ${resolved}`) } return resolved } const writeJson = (filePath, value) => { fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`) } const readJsonl = (filePath) => fs .readFileSync(filePath, 'utf8') .split(/\r?\n/) .filter(Boolean) .map((line) => JSON.parse(line)) const parseDate = (value) => { const normalized = normalizeText(value) if (!normalized || normalized === '#REF!') { return null } const directDate = new Date(normalized) if (!Number.isNaN(directDate.getTime())) { return directDate.toISOString() } return null } const buildDescription = (record) => { const parts = [ ['legacy_asset_code', record.assetCodeRaw || ''], ['source_row', record.sourceRow], ['source_status', record.currentStatusRaw || ''], ['raw_owner', record.ownerRaw || ''], ['raw_department', record.departmentRaw || ''], ['raw_location', record.addressRaw || ''], ['raw_category', record.assetCategoryRaw || ''] ] return parts .map(([key, value]) => `${key}=${String(value).replace(/;/g, ',')}`) .join('; ') } const collectDuplicateCodeDetails = (records) => { const grouped = new Map() for (const record of records) { const legacyCode = normalizeText(record.assetCodeRaw).toUpperCase() if (!legacyCode) { continue } if (!grouped.has(legacyCode)) { grouped.set(legacyCode, []) } grouped.get(legacyCode).push({ bucket: normalizeText(record.bucket), sourceRow: record.sourceRow, assetName: normalizeText(record.assetNameRaw), owner: normalizeText(record.ownerRaw), department: normalizeText(record.departmentRaw), resolvedType: normalizeText(record.assetTypeResolvedName || record.assetTypeName), address: normalizeText(record.addressRaw), purchaseDate: normalizeText(record.purchaseDateParsed), unitPrice: normalizeText(record.unitPriceParsed), sourceStatus: normalizeText(record.currentStatusRaw), statusKind: normalizeText(record.statusKind), pendingReasons: Array.isArray(record.pendingReasons) ? record.pendingReasons : [] }) } return [...grouped.entries()] .filter(([, items]) => items.length > 1) .map(([legacyCode, items]) => ({ legacyCode, items })) } const partitionImportCandidates = (records) => { const missingCodeRows = [] const validCodeRecords = [] for (const record of records) { const legacyCode = normalizeText(record.assetCodeRaw).toUpperCase() if (!legacyCode) { missingCodeRows.push({ bucket: normalizeText(record.bucket), sourceRow: record.sourceRow, assetName: normalizeText(record.assetNameRaw), owner: normalizeText(record.ownerRaw), department: normalizeText(record.departmentRaw), currentStatus: normalizeText(record.currentStatusRaw), address: normalizeText(record.addressRaw) }) continue } validCodeRecords.push(record) } const duplicateDetails = collectDuplicateCodeDetails(validCodeRecords) const duplicateCodes = new Set(duplicateDetails.map((item) => item.legacyCode)) const filteredRecords = validCodeRecords.filter( (record) => !duplicateCodes.has(normalizeText(record.assetCodeRaw).toUpperCase()) ) const duplicateRows = validCodeRecords .filter((record) => duplicateCodes.has(normalizeText(record.assetCodeRaw).toUpperCase())) .map((record) => ({ bucket: normalizeText(record.bucket), legacyCode: normalizeText(record.assetCodeRaw).toUpperCase(), sourceRow: record.sourceRow, assetName: normalizeText(record.assetNameRaw), owner: normalizeText(record.ownerRaw), department: normalizeText(record.departmentRaw), currentStatus: normalizeText(record.currentStatusRaw) })) return { filteredRecords, missingCodeRows, duplicateRows, duplicateDetails } } const buildImportPayload = (records) => { const payload = [] for (const record of records) { const legacyCode = normalizeText(record.assetCodeRaw).toUpperCase() const importMode = record.bucket === 'import-without-owner' ? 'without_owner' : 'with_owner' const statusKind = normalizeText(record.statusKind) const pendingReasons = Array.isArray(record.pendingReasons) ? record.pendingReasons : [] const matchedPerson = record.ownerMatch?.matchedPerson || null if (importMode === 'with_owner' && !matchedPerson?.id) { throw new Error(`发现未匹配 owner 的 import-ready 记录,sourceRow=${record.sourceRow} code=${legacyCode}`) } if (importMode === 'without_owner' && !['assigned', 'unassigned'].includes(statusKind)) { throw new Error( `import-without-owner 记录状态异常,无法安全导入,sourceRow=${record.sourceRow} code=${legacyCode}` ) } const descriptionExtras = [`migration_bucket=${record.bucket}`] if (importMode === 'without_owner') { descriptionExtras.push(`owner_pending_reasons=${pendingReasons.join('|') || 'none'}`) descriptionExtras.push(`owner_resolution=manual_followup_required`) } payload.push({ legacyCode, importMode, sourceStatusKind: statusKind, unresolvedOwnerReasons: pendingReasons.join('|'), rawOwnerName: normalizeText(record.ownerRaw), equipmentName: normalizeText(record.assetNameRaw), equipmentModel: normalizeText(record.equipment_model || record.modelRaw) || null, equipmentType: normalizeText(record.assetTypeResolvedName || record.assetTypeName), departmentName: normalizeText(record.departmentResolvedName || record.departmentNormalized || ''), storageLocationName: record.locationMatch ? normalizeText(record.locationResolvedName || resolveStorageLocationName(record.addressRaw).normalizedValue) : '', unitPrice: Number(record.unitPriceParsed || 0), purchaseDate: normalizeText(record.purchaseDateParsed) || null, supplier: '', description: [buildDescription(record), ...descriptionExtras].join('; '), ownerPersonId: matchedPerson?.id || null, ownerNickname: normalizeText(matchedPerson?.nickname || ''), ownerRealName: normalizeText(matchedPerson?.real_name || ''), borrowTime: parseDate(record.claimedAtRaw) || parseDate(record.purchaseDateParsed) || null, sourceRow: record.sourceRow }) } return payload } const runRemoteNodeScript = (script) => { if (!fs.existsSync(PLINK_PATH)) { throw new Error(`未找到 plink.exe: ${PLINK_PATH}`) } if (!SSH_PASSWORD) { throw new Error('缺少 FIXED_ASSET_SSH_PASSWORD 环境变量') } const result = spawnSync( PLINK_PATH, ['-batch', '-ssh', '-hostkey', SSH_HOSTKEY, '-pw', SSH_PASSWORD, SSH_HOST, REMOTE_NODE_COMMAND], { encoding: 'utf8', input: script, maxBuffer: 64 * 1024 * 1024, windowsHide: true } ) if (result.error) { throw result.error } if (result.status !== 0) { throw new Error(`远程脚本执行失败: ${result.stderr || result.stdout}`) } const stdout = normalizeText(result.stdout) if (!stdout) { throw new Error('远程脚本返回为空') } return JSON.parse(stdout) } const buildRemoteBackupScript = () => ` let PrismaClient try { ({ PrismaClient } = require('@prisma/client')) } catch { ({ PrismaClient } = require('/app/node_modules/@prisma/client')) } const prisma = new PrismaClient() ;(async () => { const [ equipment, borrowRecords, maintenanceRecords, disposalRecords, mountHistories, inventoryTasks, inventoryTaskParticipants, inventoryTaskItems, inventoryTaskActions ] = await Promise.all([ prisma.equipment.findMany({ orderBy: { id: 'asc' } }), prisma.borrowRecord.findMany({ orderBy: { id: 'asc' } }), prisma.maintenanceRecord.findMany({ orderBy: { id: 'asc' } }), prisma.disposalRecord.findMany({ orderBy: { id: 'asc' } }), prisma.mountHistory.findMany({ orderBy: { id: 'asc' } }), prisma.inventoryTask.findMany({ orderBy: { id: 'asc' } }), prisma.inventoryTaskParticipant.findMany({ orderBy: { id: 'asc' } }), prisma.inventoryTaskItem.findMany({ orderBy: { id: 'asc' } }), prisma.inventoryTaskAction.findMany({ orderBy: { id: 'asc' } }) ]) console.log(JSON.stringify({ generatedAt: new Date().toISOString(), counts: { equipment: equipment.length, borrowRecords: borrowRecords.length, maintenanceRecords: maintenanceRecords.length, disposalRecords: disposalRecords.length, mountHistories: mountHistories.length, inventoryTasks: inventoryTasks.length, inventoryTaskParticipants: inventoryTaskParticipants.length, inventoryTaskItems: inventoryTaskItems.length, inventoryTaskActions: inventoryTaskActions.length }, equipment, borrowRecords, maintenanceRecords, disposalRecords, mountHistories, inventoryTasks, inventoryTaskParticipants, inventoryTaskItems, inventoryTaskActions })) await prisma.$disconnect() })().catch(async (error) => { console.error(error && error.stack ? error.stack : String(error)) try { await prisma.$disconnect() } catch {} process.exit(1) }) ` const buildRemoteWipeScript = () => ` let PrismaClient try { ({ PrismaClient } = require('@prisma/client')) } catch { ({ PrismaClient } = require('/app/node_modules/@prisma/client')) } const prisma = new PrismaClient() ;(async () => { const before = { equipment: await prisma.equipment.count(), borrowRecords: await prisma.borrowRecord.count(), maintenanceRecords: await prisma.maintenanceRecord.count(), disposalRecords: await prisma.disposalRecord.count(), mountHistories: await prisma.mountHistory.count(), inventoryTasks: await prisma.inventoryTask.count(), inventoryTaskParticipants: await prisma.inventoryTaskParticipant.count(), inventoryTaskItems: await prisma.inventoryTaskItem.count(), inventoryTaskActions: await prisma.inventoryTaskAction.count() } await prisma.$transaction(async (tx) => { await tx.inventoryTaskAction.deleteMany({}) await tx.inventoryTaskItem.deleteMany({}) await tx.inventoryTaskParticipant.deleteMany({}) await tx.inventoryTask.deleteMany({}) await tx.maintenanceRecord.deleteMany({}) await tx.disposalRecord.deleteMany({}) await tx.mountHistory.deleteMany({}) await tx.borrowRecord.deleteMany({}) await tx.equipment.deleteMany({}) }) const after = { equipment: await prisma.equipment.count(), borrowRecords: await prisma.borrowRecord.count(), maintenanceRecords: await prisma.maintenanceRecord.count(), disposalRecords: await prisma.disposalRecord.count(), mountHistories: await prisma.mountHistory.count(), inventoryTasks: await prisma.inventoryTask.count(), inventoryTaskParticipants: await prisma.inventoryTaskParticipant.count(), inventoryTaskItems: await prisma.inventoryTaskItem.count(), inventoryTaskActions: await prisma.inventoryTaskAction.count() } console.log(JSON.stringify({ before, after })) await prisma.$disconnect() })().catch(async (error) => { console.error(error && error.stack ? error.stack : String(error)) try { await prisma.$disconnect() } catch {} process.exit(1) }) ` const buildRemoteImportScript = (payload, keepExisting) => { const payloadBase64 = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64') return ` let PrismaClient try { ({ PrismaClient } = require('@prisma/client')) } catch { ({ PrismaClient } = require('/app/node_modules/@prisma/client')) } const prisma = new PrismaClient() const payload = JSON.parse(Buffer.from('${payloadBase64}', 'base64').toString('utf8')) const keepExisting = ${keepExisting ? 'true' : 'false'} const normalizeText = (value) => String(value == null ? '' : value).replace(/\\r/g, ' ').replace(/\\n/g, ' ').replace(/\\s+/g, ' ').trim() const toDateOrNull = (value) => { const normalized = normalizeText(value) if (!normalized) return null const parsed = new Date(normalized) return Number.isNaN(parsed.getTime()) ? null : parsed } const calculateValues = (unitPrice, remainingQuantity) => { const normalizedUnitPrice = Number(unitPrice || 0) const totalValue = normalizedUnitPrice const borrowedQuantity = Math.max(1 - Number(remainingQuantity || 0), 0) return { totalValue, borrowedValue: normalizedUnitPrice * borrowedQuantity, availableValue: normalizedUnitPrice * Number(remainingQuantity || 0) } } ;(async () => { const [assetTypes, departments, locations] = await Promise.all([ prisma.assetType.findMany({ where: { status: 'active' }, select: { id: true, name: true } }), prisma.department.findMany({ where: { status: 'active', source_system: 'ding_hr_business' }, select: { id: true, name: true } }), prisma.storageLocation.findMany({ where: { status: 'active' }, select: { id: true, name: true } }) ]) const assetTypeMap = new Map(assetTypes.map((item) => [item.name, item])) const departmentMap = new Map(departments.map((item) => [item.name, item])) const locationMap = new Map(locations.map((item) => [item.name, item])) const duplicatePayloadCodes = new Set() const payloadCodes = new Set() for (const item of payload) { if (payloadCodes.has(item.legacyCode)) { duplicatePayloadCodes.add(item.legacyCode) } payloadCodes.add(item.legacyCode) } if (duplicatePayloadCodes.size > 0) { throw new Error('导入 payload 中存在重复资产编码: ' + [...duplicatePayloadCodes].join(', ')) } const existingCodes = await prisma.equipment.findMany({ where: { equipment_code: { in: [...payloadCodes] } }, select: { equipment_code: true } }) if (!keepExisting && existingCodes.length > 0) { throw new Error('线上已存在同名资产编码: ' + existingCodes.map((item) => item.equipment_code).join(', ')) } const existingCodeSet = new Set(existingCodes.map((item) => item.equipment_code)) const results = [] const skippedExisting = [] for (const item of payload) { if (existingCodeSet.has(item.legacyCode)) { skippedExisting.push({ legacyCode: item.legacyCode, importMode: item.importMode, reason: 'already_exists' }) continue } const assetType = assetTypeMap.get(item.equipmentType) if (!assetType) { throw new Error('资产类型不存在: ' + item.equipmentType + ' code=' + item.legacyCode) } const department = item.departmentName ? departmentMap.get(item.departmentName) || null : null if (item.departmentName && !department) { throw new Error('业务部门不存在: ' + item.departmentName + ' code=' + item.legacyCode) } const location = item.storageLocationName ? locationMap.get(item.storageLocationName) || null : null if (item.storageLocationName && !location) { throw new Error('存放地点不存在: ' + item.storageLocationName + ' code=' + item.legacyCode) } let person = null if (item.importMode === 'with_owner') { person = await prisma.person.findFirst({ where: { id: item.ownerPersonId, status: 'active', source_system: 'ding_hr' }, select: { id: true, real_name: true, nickname: true, id_number: true } }) if (!person) { throw new Error('使用人不存在或不再是 active: personId=' + item.ownerPersonId + ' code=' + item.legacyCode) } } const unitPrice = Number(item.unitPrice || 0) const purchaseDate = toDateOrNull(item.purchaseDate) const borrowTime = toDateOrNull(item.borrowTime) || new Date() let equipmentStatus = '可用' let remainingQuantity = 1 let borrowRecordNeeded = false let borrowNotes = 'Excel迁移导入;在职长期' if (item.importMode === 'with_owner') { equipmentStatus = '已借出' remainingQuantity = 0 borrowRecordNeeded = true } else if (item.sourceStatusKind === 'assigned') { equipmentStatus = '已借出' remainingQuantity = 0 borrowNotes = 'Excel迁移导入;当前使用人待行政补录' } else if (item.sourceStatusKind === 'unassigned') { equipmentStatus = '可用' remainingQuantity = 1 } else { throw new Error('ownerless 资产状态异常: ' + item.sourceStatusKind + ' code=' + item.legacyCode) } const values = calculateValues(unitPrice, remainingQuantity) const created = await prisma.$transaction(async (tx) => { const equipment = await tx.equipment.create({ data: { equipment_code: item.legacyCode, equipment_name: item.equipmentName, equipment_model: normalizeText(item.equipmentModel) || null, equipment_type: assetType.name, code_generation_mode: 'manual', status: equipmentStatus, total_quantity: 1, remaining_quantity: remainingQuantity, unit_price: unitPrice, total_value: values.totalValue, borrowed_value: values.borrowedValue, available_value: values.availableValue, total_value_with_children: values.totalValue, description: normalizeText(item.description) || null, purchase_date: purchaseDate, supplier: normalizeText(item.supplier) || null, storage_location_id: location?.id, asset_type_id: assetType.id, department_id: department?.id, mount_status: 'none' } }) if (borrowRecordNeeded && person) { await tx.borrowRecord.create({ data: { equipment_id: equipment.id, equipment_code: equipment.equipment_code, person_id: person.id, person_name: person.real_name, person_nickname: person.nickname, person_identifier: person.id_number, borrow_time: borrowTime, expected_return_time: null, is_long_term: true, return_time: null, status: '已借出', return_condition: '正常', notes: borrowNotes } }) } return { id: equipment.id, equipment_code: equipment.equipment_code, importMode: item.importMode, status: equipment.status, person_id: person?.id || null, person_nickname: person?.nickname || null } }) results.push(created) } const verification = { equipment: await prisma.equipment.count(), borrowRecords: await prisma.borrowRecord.count(), activeBorrow: await prisma.borrowRecord.count({ where: { status: '已借出' } }), importedCodeCount: await prisma.equipment.count({ where: { equipment_code: { in: [...payloadCodes] } } }), importedWithOwnerCount: results.filter((item) => item.importMode === 'with_owner').length, importedWithoutOwnerCount: results.filter((item) => item.importMode === 'without_owner').length, skippedExistingCount: skippedExisting.length } console.log(JSON.stringify({ imported: results.length, verification, skippedExisting, sample: results.slice(0, 10) })) await prisma.$disconnect() })().catch(async (error) => { console.error(error && error.stack ? error.stack : String(error)) try { await prisma.$disconnect() } catch {} process.exit(1) }) ` } const buildRemoteVerifyScript = (payload) => { const payloadBase64 = Buffer.from( JSON.stringify(payload.map((item) => item.legacyCode)), 'utf8' ).toString('base64') return ` let PrismaClient try { ({ PrismaClient } = require('@prisma/client')) } catch { ({ PrismaClient } = require('/app/node_modules/@prisma/client')) } const prisma = new PrismaClient() const codes = JSON.parse(Buffer.from('${payloadBase64}', 'base64').toString('utf8')) ;(async () => { const [equipment, borrowRecords, activeBorrow, importedEquipments] = await Promise.all([ prisma.equipment.count(), prisma.borrowRecord.count(), prisma.borrowRecord.count({ where: { status: '已借出' } }), prisma.equipment.findMany({ where: { equipment_code: { in: codes } }, orderBy: { equipment_code: 'asc' }, take: 20, select: { equipment_code: true, equipment_name: true, equipment_type: true, status: true, remaining_quantity: true, department: { select: { name: true } }, storageLocation: { select: { name: true } } } }) ]) console.log(JSON.stringify({ equipment, borrowRecords, activeBorrow, importedCodeCount: await prisma.equipment.count({ where: { equipment_code: { in: codes } } }), sample: importedEquipments })) await prisma.$disconnect() })().catch(async (error) => { console.error(error && error.stack ? error.stack : String(error)) try { await prisma.$disconnect() } catch {} process.exit(1) }) ` } const run = () => { const options = parseArgs() const inputDir = resolveInputDir(options.inputDir) const normalizedPath = path.join(inputDir, 'normalized_assets.jsonl') if (!fs.existsSync(normalizedPath)) { throw new Error(`未找到 normalized_assets.jsonl: ${normalizedPath}`) } const normalizedRecords = readJsonl(normalizedPath) const importReadyRecords = normalizedRecords.filter((record) => record.bucket === 'import-ready') const importWithoutOwnerRecords = options.includeOwnerless ? normalizedRecords.filter((record) => record.bucket === 'import-without-owner') : [] const importCandidates = [...importReadyRecords, ...importWithoutOwnerRecords] if (!importCandidates.length) { throw new Error('没有可导入的确认记录') } const partitioned = partitionImportCandidates(importCandidates) const payload = buildImportPayload(partitioned.filteredRecords) const backupDir = ensureOutputDir('live_import_backup') writeJson(path.join(backupDir, 'excluded_missing_code_rows.json'), { excludedMissingCodeCount: partitioned.missingCodeRows.length, rows: partitioned.missingCodeRows }) writeJson(path.join(backupDir, 'excluded_duplicate_codes.json'), { excludedDuplicateCodeCount: partitioned.duplicateDetails.length, excludedDuplicateRecordCount: partitioned.duplicateRows.length, duplicateDetails: partitioned.duplicateDetails, excludedRows: partitioned.duplicateRows }) writeJson(path.join(backupDir, 'planned_import_summary.json'), { inputDir, importReadyCount: importReadyRecords.length, importWithoutOwnerCount: importWithoutOwnerRecords.length, selectedCandidateCount: importCandidates.length, excludedMissingCodeCount: partitioned.missingCodeRows.length, excludedDuplicateRecordCount: partitioned.duplicateRows.length, excludedDuplicateCodeCount: partitioned.duplicateDetails.length, finalImportCount: payload.length, finalImportReadyCount: payload.filter((item) => item.importMode === 'with_owner').length, finalImportWithoutOwnerCount: payload.filter((item) => item.importMode === 'without_owner').length, keepExisting: options.keepExisting, includeOwnerless: options.includeOwnerless }) writeJson(path.join(backupDir, 'planned_payload.json'), { payloadCount: payload.length, payload }) const backup = runRemoteNodeScript(buildRemoteBackupScript()) writeJson(path.join(backupDir, 'pre_import_live_backup.json'), backup) let wipeResult = { skipped: true } if (!options.keepExisting) { wipeResult = runRemoteNodeScript(buildRemoteWipeScript()) writeJson(path.join(backupDir, 'wipe_result.json'), wipeResult) } const importResult = runRemoteNodeScript(buildRemoteImportScript(payload, options.keepExisting)) writeJson(path.join(backupDir, 'import_result.json'), importResult) const verification = runRemoteNodeScript(buildRemoteVerifyScript(payload)) writeJson(path.join(backupDir, 'post_import_verification.json'), verification) console.log( JSON.stringify( { backupDir, inputDir, importedCount: importResult.imported, wipeResult, importResult, verification }, null, 2 ) ) } try { run() } catch (error) { console.error(error && error.stack ? error.stack : String(error)) process.exit(1) }