diff --git a/dist/index.js b/dist/index.js index acdce5a2..4011f740 100644 --- a/dist/index.js +++ b/dist/index.js @@ -3523,6 +3523,150 @@ async function execSdkManager(sdkManagerPath, javaPath, args) { /***/ }), +/***/ 9644: +/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { + +"use strict"; + +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.GitHubActionsLoggerProvider = exports.GitHubAnnotationLevel = void 0; +exports.isUnityCliWorkflowSummaryEnabled = isUnityCliWorkflowSummaryEnabled; +const fs = __importStar(__nccwpck_require__(7147)); +var GitHubAnnotationLevel; +(function (GitHubAnnotationLevel) { + GitHubAnnotationLevel["Notice"] = "notice"; + GitHubAnnotationLevel["Warning"] = "warning"; + GitHubAnnotationLevel["Error"] = "error"; +})(GitHubAnnotationLevel || (exports.GitHubAnnotationLevel = GitHubAnnotationLevel = {})); +/** When set to 1/true/yes/on (case-insensitive), unity-cli may append to `GITHUB_STEP_SUMMARY`. Default: off. */ +function isUnityCliWorkflowSummaryEnabled() { + const v = process.env.UNITY_CLI_WORKFLOW_SUMMARY?.trim().toLowerCase(); + return v === '1' || v === 'true' || v === 'yes' || v === 'on'; +} +class GitHubActionsLoggerProvider { + isCi = process.env.GITHUB_ACTIONS === 'true'; + log(level, message, optionalParams = []) { + switch (level) { + case 'debug': { + message.toString().split('\n').forEach((line) => { + process.stdout.write(`::debug::${line}\n`, ...optionalParams); + }); + break; + } + case 'ci': + case 'info': + process.stdout.write(`${message}\n`, ...optionalParams); + break; + default: + process.stdout.write(`::${level}::${message}\n`, ...optionalParams); + break; + } + } + startGroup(message, optionalParams = []) { + const firstLine = message.toString().split('\n')[0]; + process.stdout.write(`::group::${firstLine}\n`, ...optionalParams); + } + endGroup() { + process.stdout.write('::endgroup::\n'); + } + annotate(level, message, options) { + const parts = []; + const appendPart = (key, value) => { + if (value === undefined || value === null) { + return; + } + const stringValue = value.toString(); + if (stringValue.length === 0) { + return; + } + parts.push(`${key}=${this.escapeGitHubCommandValue(stringValue)}`); + }; + appendPart('file', options?.file); + if (options?.line !== undefined && options.line > 0) + appendPart('line', options.line); + if (options?.endLine !== undefined && options.endLine > 0) + appendPart('endLine', options.endLine); + if (options?.column !== undefined && options.column > 0) + appendPart('col', options.column); + if (options?.endColumn !== undefined && options.endColumn > 0) + appendPart('endColumn', options.endColumn); + appendPart('title', options?.title); + const metadata = parts.length > 0 ? ` ${parts.join(',')}` : ''; + process.stdout.write(`::${level}${metadata}::${this.escapeGitHubCommandValue(message)}\n`); + } + mask(message) { + process.stdout.write(`::add-mask::${message}\n`); + } + setEnvironmentVariable(name, value) { + const githubEnv = process.env.GITHUB_ENV; + if (githubEnv) { + fs.appendFileSync(githubEnv, `${name}=${value}\n`, { encoding: 'utf8' }); + } + } + setOutput(name, value) { + const githubOutput = process.env.GITHUB_OUTPUT; + if (githubOutput) { + fs.appendFileSync(githubOutput, `${name}=${value}\n`, { encoding: 'utf8' }); + } + } + appendStepSummary(summary) { + const githubSummary = process.env.GITHUB_STEP_SUMMARY; + if (!githubSummary) { + return; + } + fs.appendFileSync(githubSummary, summary, { encoding: 'utf8' }); + } + getMarkdownByteLimit(target) { + if (target === 'workflow-summary' && isUnityCliWorkflowSummaryEnabled()) { + return 1024 * 1024; + } + return Number.POSITIVE_INFINITY; + } + escapeGitHubCommandValue(value) { + return value + .replace(/%/g, '%25') + .replace(/\r/g, '%0D') + .replace(/\n/g, '%0A'); + } +} +exports.GitHubActionsLoggerProvider = GitHubActionsLoggerProvider; +//# sourceMappingURL=github-actions-ci.js.map + +/***/ }), + /***/ 4858: /***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { @@ -3553,6 +3697,7 @@ __exportStar(__nccwpck_require__(8468), exports); __exportStar(__nccwpck_require__(3331), exports); __exportStar(__nccwpck_require__(9746), exports); __exportStar(__nccwpck_require__(6753), exports); +__exportStar(__nccwpck_require__(7501), exports); //# sourceMappingURL=index.js.map /***/ }), @@ -4218,47 +4363,587 @@ exports.LicensingClient = LicensingClient; /***/ }), -/***/ 4486: -/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { +/***/ 2416: +/***/ ((__unused_webpack_module, exports) => { "use strict"; -var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { - if (k2 === undefined) k2 = k; - var desc = Object.getOwnPropertyDescriptor(m, k); - if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { - desc = { enumerable: true, get: function() { return m[k]; } }; +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.LocalCliLoggerProvider = void 0; +class LocalCliLoggerProvider { + isCi = false; + log(level, message, optionalParams = []) { + const stringColor = { + debug: '\x1b[35m', + ci: undefined, + utp: undefined, + info: undefined, + warning: '\x1b[33m', + error: '\x1b[31m', + }[level]; + if (stringColor && stringColor.length > 0) { + process.stdout.write(`${stringColor}${message}\x1b[0m\n`, ...optionalParams); + return; + } + process.stdout.write(`${message}\n`, ...optionalParams); } - Object.defineProperty(o, k2, desc); -}) : (function(o, m, k, k2) { - if (k2 === undefined) k2 = k; - o[k2] = m[k]; -})); -var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { - Object.defineProperty(o, "default", { enumerable: true, value: v }); -}) : function(o, v) { - o["default"] = v; -}); -var __importStar = (this && this.__importStar) || (function () { - var ownKeys = function(o) { - ownKeys = Object.getOwnPropertyNames || function (o) { - var ar = []; - for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; - return ar; - }; - return ownKeys(o); - }; - return function (mod) { - if (mod && mod.__esModule) return mod; - var result = {}; - if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); - __setModuleDefault(result, mod); - return result; - }; -})(); + startGroup(message, optionalParams = []) { + this.log('info', message, optionalParams); + } + endGroup() { + // no-op for local terminal + } + annotate(level, message) { + const mapped = level === 'error' ? 'error' : (level === 'warning' ? 'warning' : 'info'); + this.log(mapped, message); + } + mask(_message) { + // no-op for local terminal + } + setEnvironmentVariable(_name, _value) { + // no-op for local terminal + } + setOutput(_name, _value) { + // no-op for local terminal + } + appendStepSummary(_summary) { + // no-op for local terminal + } + getMarkdownByteLimit(_target) { + return Number.POSITIVE_INFINITY; + } +} +exports.LocalCliLoggerProvider = LocalCliLoggerProvider; +//# sourceMappingURL=logger-provider.js.map + +/***/ }), + +/***/ 4486: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + Object.defineProperty(exports, "__esModule", ({ value: true })); exports.Logger = exports.LogLevel = void 0; -const fs = __importStar(__nccwpck_require__(7147)); +exports.mergeLogEntriesPreferringSeverity = mergeLogEntriesPreferringSeverity; +exports.testStatusFromState = testStatusFromState; +exports.utpToTestResultSummary = utpToTestResultSummary; +exports.buildTestResultsTableMarkdown = buildTestResultsTableMarkdown; +exports.buildUnitTestJobSummaryMarkdown = buildUnitTestJobSummaryMarkdown; +exports.truncateStringToUtf8ByteLength = truncateStringToUtf8ByteLength; +exports.stripSummaryNoiseFromLogMessage = stripSummaryNoiseFromLogMessage; +const utp_1 = __nccwpck_require__(6282); +const utp_benign_1 = __nccwpck_require__(6239); +const github_actions_ci_1 = __nccwpck_require__(9644); +const logger_provider_1 = __nccwpck_require__(2416); +/** Severity order for display: Error first, then Warning, then Info. Undefined treats as Warning. */ +function severityRank(s) { + if (s === utp_1.Severity.Error || s === utp_1.Severity.Exception || s === utp_1.Severity.Assert) + return 0; + if (s === utp_1.Severity.Warning || s === undefined) + return 1; + return 2; // Info +} +function dedupeKey(e) { + const msg = (e.message || '').trim(); + const file = (e.file || e.fileName || '').replace(/\\/g, '/'); + const line = e.line ?? e.lineNumber ?? 0; + return `${msg}\n${file}\n${line}`; +} +/** + * Returns true if the path looks absolute (Unix / or Windows X:/). + */ +function isAbsolutePath(file) { + const norm = file.replace(/\\/g, '/'); + if (norm.startsWith('/')) + return true; + return /^[a-zA-Z]:\//.test(norm); +} +/** + * Returns true if the entry's file is under the project path (or entry has no file). + * Relative paths (e.g. Assets/..., Packages/...) are always kept so Unity UTP log/compiler + * entries with relative file paths still appear in the summary. + */ +function isEntryUnderProjectPath(e, projectPath) { + const file = (e.file || e.fileName || '').trim(); + if (!file) + return true; + const normFile = file.replace(/\\/g, '/'); + if (!isAbsolutePath(normFile)) + return true; + const normProject = projectPath.replace(/\\/g, '/'); + const base = normProject.endsWith('/') ? normProject : normProject + '/'; + return normFile === normProject || normFile.startsWith(base); +} +/** + * Returns true if the entry's file looks like a Unity engine path (should be omitted when not using projectPath). + */ +function isUnityEnginePath(file) { + const norm = file.replace(/\\/g, '/'); + if (UNITY_ENGINE_PATH_PREFIXES.some(p => norm.startsWith(p))) + return true; + if (norm.includes('/Runtime/') || norm.includes('\\Runtime\\')) + return true; + if (!norm.endsWith('.cpp')) + return false; + const underProject = norm.includes('/Assets/') || norm.includes('/Packages/') || norm.includes('/Library/PackageCache/'); + return !underProject; +} +/** + * Merges LogEntry/Compiler rows by message+file+line; on collision keeps the more severe entry. + * Exported for unit tests. + */ +function mergeLogEntriesPreferringSeverity(candidates) { + const byKey = new Map(); + for (const e of candidates) { + const key = dedupeKey(e); + const existing = byKey.get(key); + if (!existing || severityRank(e.severity) < severityRank(existing.severity)) { + byKey.set(key, e); + } + } + const merged = [...byKey.values()]; + merged.sort((a, b) => severityRank(a.severity) - severityRank(b.severity)); + return merged; +} +/** + * Builds one merged list from LogEntry and Compiler entries. + * Deduplicated by message+file+line (keeping worse severity on collision), sorted by severity. + */ +function buildMergedLogList(filtered) { + const candidates = filtered.filter(e => e.type === 'LogEntry' || e.type === 'Compiler'); + return mergeLogEntriesPreferringSeverity(candidates); +} +/** + * Filters merged list to project-relevant entries only. + * When projectPath is set: keep entries with no file or file under projectPath. + * When projectPath is not set: exclude Unity engine paths only (keep PackageCache and project paths). + */ +function filterMergedByPath(merged, options) { + if (options?.projectPath != null && options.projectPath !== '') { + return merged.filter(e => isEntryUnderProjectPath(e, options.projectPath)); + } + return merged.filter(e => { + const file = (e.file || e.fileName || '').trim(); + if (!file) + return true; + return !isUnityEnginePath(file); + }); +} +/** Groups merged log by severity for foldouts (Error, Warning, Info). Missing severity is grouped as Warning. */ +function groupBySeverity(merged) { + const errorCritical = []; + const warning = []; + const info = []; + for (const e of merged) { + if (e.severity === utp_1.Severity.Error || e.severity === utp_1.Severity.Exception || e.severity === utp_1.Severity.Assert) { + errorCritical.push(e); + } + else if (e.severity === utp_1.Severity.Warning || e.severity === undefined) { + warning.push(e); + } + else { + info.push(e); + } + } + return { errorCritical, warning, info }; +} +/** Maps UTPTestStatus.state to display status (Unity/NUnit-style: 0 Inconclusive, 1 Passed, 2 Failed, 3 Skipped). */ +function testStatusFromState(state) { + switch (state) { + case 1: return '✅'; + case 2: return '❌'; + case 3: return '⏭️'; + case 0: + default: return '◯'; + } +} +/** Converts a single TestStatus UTP to TestResultSummary. Exported for CLI use. */ +function utpToTestResultSummary(e) { + const state = e.state; + const durationMs = e.duration ?? (e.durationMicroseconds != null ? e.durationMicroseconds / 1000 : 0); + const description = (e.name || e.description || '-').trim(); + const msg = (e.message || '').trim(); + const summary = { + status: testStatusFromState(state), + durationMs, + description, + }; + if (msg !== '') { + summary.message = msg; + } + const file = (e.file || e.fileName || '').trim(); + const line = e.line ?? e.lineNumber; + if (file !== '') { + summary.file = file.replace(/\\/g, '/'); + } + if (line !== undefined && line > 0) { + summary.line = line; + } + return summary; +} +/** Collects TestStatus entries from telemetry into TestResultSummary rows. */ +function collectTestResults(filtered) { + return filtered.filter(e => e.type === 'TestStatus').map(utpToTestResultSummary); +} +function escapeMarkdownTableCell(value) { + return value + .replace(/\\/g, '\\\\') + .replace(/\|/g, '\\|'); +} +/** Builds a markdown table string for test results (Status | Duration | Test). Exported for CLI use. */ +function buildTestResultsTableMarkdown(testResults, byteLimit, prefix) { + if (testResults.length === 0) + return ''; + const p = prefix ?? ''; + let out = p + `### Test results\n\n`; + out += `| Status | Duration | Test |\n`; + out += `|--------|----------|------|\n`; + let shown = 0; + for (const row of testResults) { + const durationStr = row.durationMs >= 1000 + ? `${(row.durationMs / 1000).toFixed(1)}s` + : `${Math.round(row.durationMs)} ms`; + const rawDesc = row.description.length > 80 ? row.description.slice(0, 77) + '…' : row.description; + const desc = escapeMarkdownTableCell(rawDesc); + const line = `| ${escapeMarkdownTableCell(row.status)} | ${escapeMarkdownTableCell(durationStr)} | ${desc} |\n`; + if (Buffer.byteLength(out + line, 'utf8') > byteLimit) + break; + out += line; + shown++; + } + if (shown < testResults.length) { + out += `| … | … | … and ${testResults.length - shown} more |\n`; + } + out += `\n`; + return out; +} +function summarizeTestOutcomes(testResults) { + let passed = 0; + let failed = 0; + let skipped = 0; + let inconclusive = 0; + let totalDurationMs = 0; + for (const t of testResults) { + totalDurationMs += t.durationMs; + switch (t.status) { + case '✅': + passed++; + break; + case '❌': + failed++; + break; + case '⏭️': + skipped++; + break; + default: + inconclusive++; + break; + } + } + return { passed, failed, skipped, inconclusive, totalDurationMs }; +} +/** + * Rich unit-test markdown block used by workflow summary and stdout. + * Keeps byte-budget behavior and truncation hints. + */ +function buildUnitTestJobSummaryMarkdown(testResults, byteLimit, prefix) { + if (testResults.length === 0) + return ''; + const p = prefix ?? ''; + let out = p + '### Unit test results\n\n'; + const counts = summarizeTestOutcomes(testResults); + const durationStr = counts.totalDurationMs >= 1000 + ? `${(counts.totalDurationMs / 1000).toFixed(1)}s` + : `${Math.round(counts.totalDurationMs)} ms`; + out += `**${testResults.length}** tests - **${counts.passed}** ✓, **${counts.failed}** ✗, **${counts.skipped}** skipped, **${counts.inconclusive}** inconclusive - **${durationStr}** total\n\n`; + out += '| Test | Result | Time | Message |\n'; + out += '| --- | --- | --- | --- |\n'; + const ordered = [...testResults].sort((a, b) => { + const aFail = a.status === '❌' ? 0 : 1; + const bFail = b.status === '❌' ? 0 : 1; + if (aFail !== bFail) + return aFail - bFail; + return b.durationMs - a.durationMs; + }); + let shown = 0; + for (const row of ordered) { + const durationText = row.durationMs >= 1000 ? `${(row.durationMs / 1000).toFixed(1)}s` : `${Math.round(row.durationMs)} ms`; + const loc = row.file && row.line ? ` (${row.file}:${row.line})` : ''; + const rawName = `${row.description}${loc}`; + const name = escapeMarkdownTableCell(rawName.length > 90 ? `${rawName.slice(0, 87)}…` : rawName); + const msgRaw = (row.message ?? '').replace(/\r?\n/g, ' ').trim(); + const msg = escapeMarkdownTableCell(msgRaw.length > 120 ? `${msgRaw.slice(0, 117)}…` : msgRaw); + const line = `| ${name} | ${escapeMarkdownTableCell(row.status)} | ${escapeMarkdownTableCell(durationText)} | ${msg} |\n`; + if (Buffer.byteLength(out + line, 'utf8') > byteLimit) + break; + out += line; + shown++; + } + if (shown < ordered.length) { + out += `| … | … | … | … and ${ordered.length - shown} more |\n`; + } + out += '\n'; + return out; +} +function buildActionTimelineTableMarkdown(completedActions, byteLimit, prefix) { + if (completedActions.length === 0) + return { markdown: '', truncated: false }; + const p = prefix ?? ''; + let out = p + '| Status | Duration | Errors | Action |\n'; + out += '| --- | --- | --- | --- |\n'; + let shown = 0; + for (const a of completedActions) { + const durationMs = a.duration ?? (a.durationMicroseconds != null ? a.durationMicroseconds / 1000 : undefined); + const errCount = Array.isArray(a.errors) ? a.errors.length : 0; + const status = errCount > 0 ? '❌' : '✅'; + const action = truncateStr(toSingleLineText(a.description || a.name || '-'), 120); + const row = `| ${escapeMarkdownTableCell(status)} | ${escapeMarkdownTableCell(formatDurationMsForSummary(durationMs))} | ${errCount} | ${escapeMarkdownTableCell(action)} |\n`; + if (Buffer.byteLength(out + row, 'utf8') > byteLimit) + break; + out += row; + shown++; + } + const truncated = shown < completedActions.length; + if (truncated) { + out += `| ... | ... | ... | ... and ${completedActions.length - shown} more actions |\n`; + } + out += '\n'; + return { markdown: out, truncated }; +} +function buildActionTimelineCodeblockMarkdown(completedActions, byteLimit, prefix) { + if (completedActions.length === 0) + return ''; + const p = prefix ?? ''; + let out = p + '```text\n'; + let timelineShown = 0; + for (const a of completedActions) { + const durationMs = a.duration ?? (a.durationMicroseconds != null ? a.durationMicroseconds / 1000 : undefined); + const errCount = Array.isArray(a.errors) ? a.errors.length : 0; + const status = errCount > 0 ? '❌' : '✅'; + const desc = toSingleLineText(a.description || a.name || '-'); + const durationStr = formatDurationMsForSummary(durationMs); + const row = `${status} ${durationStr} ${errCount} - ${desc}\n`; + if (Buffer.byteLength(out + row, 'utf8') > byteLimit) + break; + out += row; + timelineShown++; + } + if (timelineShown < completedActions.length) { + out += `... and ${completedActions.length - timelineShown} more actions\n`; + } + out += '```\n\n'; + return out; +} +function truncateStr(s, max) { + return s.length <= max ? s : s.slice(0, max) + '…'; +} +/** + * Truncates s to fit within maxBytes in UTF-8. If truncated, appends an ellipsis (…). + * If s already fits, returns s unchanged. + * Exported for unit tests. + */ +function truncateStringToUtf8ByteLength(s, maxBytes) { + if (maxBytes <= 0) + return ''; + const ellipsis = '…'; + const ellBytes = Buffer.byteLength(ellipsis, 'utf8'); + if (Buffer.byteLength(s, 'utf8') <= maxBytes) + return s; + if (maxBytes <= ellBytes) { + let end = 0; + for (let i = 1; i <= s.length; i++) { + const sub = s.slice(0, i); + if (Buffer.byteLength(sub, 'utf8') > maxBytes) + break; + end = i; + } + return s.slice(0, end); + } + let low = 0; + let high = s.length; + while (low < high) { + const mid = Math.floor((low + high + 1) / 2); + const sub = s.slice(0, mid); + if (Buffer.byteLength(sub, 'utf8') + ellBytes <= maxBytes) + low = mid; + else + high = mid - 1; + } + return s.slice(0, low) + ellipsis; +} +/** + * Appends one formatted log line per entry, truncating each line only when it would exceed the + * remaining bytes in the workflow summary (byteLimit is total cap for the final string starting from out). + */ +function appendWorkflowSummaryLogLines(out, entries, byteLimit) { + let o = out; + let shown = 0; + const newline = '\n'; + const nlBytes = Buffer.byteLength(newline, 'utf8'); + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + if (entry === undefined) { + return { out: o, shown, omitted: entries.length - shown }; + } + const room = byteLimit - Buffer.byteLength(o, 'utf8'); + if (room < nlBytes) { + return { out: o, shown, omitted: entries.length - shown }; + } + const rawLine = formatLogEntryLine(entry, Number.POSITIVE_INFINITY).replace(/\n$/, ''); + const maxContentBytes = room - nlBytes; + const lineBody = Buffer.byteLength(rawLine, 'utf8') <= maxContentBytes + ? rawLine + : truncateStringToUtf8ByteLength(rawLine, maxContentBytes); + o += lineBody + newline; + shown++; + } + return { out: o, shown, omitted: 0 }; +} +function toSingleLineText(value) { + return value + .replace(/\r?\n+/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} +function formatDurationMsForSummary(ms) { + if (ms === undefined || !Number.isFinite(ms)) { + return '-'; + } + if (ms < 1000) { + return `${Math.round(ms)}ms`; + } + return `${(ms / 1000).toFixed(1)}s`; +} +/** Unity/CI noise shown in logs; omit from workflow summary foldouts and counts. */ +function buildSummaryNoisePatterns() { + return utp_benign_1.UTP_BENIGN_SEVERITY_REMAPS.map(({ fragment }) => { + const escaped = fragment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + // Multicast lines often include "(err: 10013)." — strip the whole clause. + if (fragment.includes('multicast group')) { + return new RegExp(`${escaped}(?:\\s*\\(err:\\s*\\d+\\))?\\.?`, 'gi'); + } + return new RegExp(escaped, 'gi'); + }); +} +const SUMMARY_NOISE_PATTERNS = buildSummaryNoisePatterns(); +/** + * Removes known noise phrases from a log message for summary display. + * Exported for unit tests. Fragments come from {@link UTP_BENIGN_SEVERITY_REMAPS}. + */ +function stripSummaryNoiseFromLogMessage(message) { + const flat = toSingleLineText(message); + if (!flat) + return ''; + let out = flat; + for (const pattern of SUMMARY_NOISE_PATTERNS) { + pattern.lastIndex = 0; + out = out.replace(pattern, ' '); + } + return out.replace(/\s+/g, ' ').trim(); +} +function filterNoiseFromSummaryLogEntries(entries) { + const out = []; + for (const e of entries) { + const stripped = stripSummaryNoiseFromLogMessage(e.message || ''); + if (stripped === '') + continue; + const originalFlat = toSingleLineText(e.message || ''); + if (stripped !== originalFlat) { + out.push({ ...e, message: stripped }); + } + else { + out.push(e); + } + } + return out; +} +function renderBuildActionsFoldoutMarkdown(completedActions, maxBytes) { + const n = completedActions.length; + const open = `
Build actions (${n})\n\n`; + const close = `
\n\n`; + const overhead = Buffer.byteLength(open + close, 'utf8'); + const innerBudget = Math.max(0, maxBytes - overhead); + const table = buildActionTimelineTableMarkdown(completedActions, innerBudget, ''); + const inner = !table.truncated + ? table.markdown + : buildActionTimelineCodeblockMarkdown(completedActions, innerBudget, ''); + return open + inner + close; +} +/** Paths to treat as Unity engine (omit from summary when using heuristic filter). */ +const UNITY_ENGINE_PATH_PREFIXES = [ + 'Runtime/', + './Runtime/', + 'Modules/', + './Modules/', +]; +/** + * Normalizes a log message for display by stripping a redundant file:line prefix + * when it matches the entry's file/line so the path appears only once. + * Returns the normalized message and optional column if present in the prefix. + */ +function normalizeMessageForDisplay(message, file, line) { + const trimmed = message.trim(); + const normFile = file.replace(/\\/g, '/'); + if (!normFile && line === undefined) + return { message: trimmed }; + // path(line,col): e.g. Assets/File.cs(2,8): error ... + const parenColon = trimmed.match(/^(.+?)\((\d+),(\d+)\):\s*/); + if (parenColon && parenColon[1] != null && parenColon[2] != null && parenColon[3] != null) { + const fullMatch = parenColon[0]; + const msgPath = parenColon[1].replace(/\\/g, '/'); + const msgLine = parseInt(parenColon[2], 10); + const msgCol = parseInt(parenColon[3], 10); + const pathMatches = msgPath === normFile || normFile.endsWith(msgPath) || msgPath.endsWith(normFile); + if (pathMatches && (line === undefined || line === msgLine)) { + return { message: trimmed.slice(fullMatch.length).trim(), column: msgCol }; + } + } + // path(line): e.g. Assets/File.cs(2): ... + const parenOnly = trimmed.match(/^(.+?)\((\d+)\):\s*/); + if (parenOnly && parenOnly[1] != null && parenOnly[2] != null) { + const fullMatch = parenOnly[0]; + const msgPath = parenOnly[1].replace(/\\/g, '/'); + const msgLine = parseInt(parenOnly[2], 10); + const pathMatches = msgPath === normFile || normFile.endsWith(msgPath) || msgPath.endsWith(normFile); + if (pathMatches && (line === undefined || line === msgLine)) { + return { message: trimmed.slice(fullMatch.length).trim() }; + } + } + // path:line: e.g. path/to/file.cs:10: + const pathLineColon = trimmed.match(/^(.+?):(\d+):\s*/); + if (pathLineColon && pathLineColon[1] != null && pathLineColon[2] != null) { + const fullMatch = pathLineColon[0]; + const msgPath = pathLineColon[1].replace(/\\/g, '/'); + const msgLine = parseInt(pathLineColon[2], 10); + const pathMatches = msgPath === normFile || normFile.endsWith(msgPath) || msgPath.endsWith(normFile); + if (pathMatches && (line === undefined || line === msgLine)) { + return { message: trimmed.slice(fullMatch.length).trim() }; + } + } + return { message: trimmed }; +} +/** + * One line per entry: path(line,col): <message> or path(line): <message> when column is missing. + * When file/line are missing, outputs: - <message>. + */ +function formatLogEntryLine(e, maxMsgLen = Number.POSITIVE_INFINITY) { + const file = (e.file || e.fileName || '').replace(/\\/g, '/'); + const line = e.line ?? e.lineNumber; + const hasLocation = file && (line !== undefined && line > 0); + const rawMsg = toSingleLineText(e.message || ''); + const { message: normalizedMsg, column } = hasLocation + ? normalizeMessageForDisplay(rawMsg, file, line) + : { message: rawMsg, column: undefined }; + const msg = Number.isFinite(maxMsgLen) && maxMsgLen >= 0 && maxMsgLen < Number.POSITIVE_INFINITY + ? truncateStr(normalizedMsg, maxMsgLen) + : normalizedMsg; + if (hasLocation) { + const loc = column !== undefined ? `${file}(${line},${column})` : `${file}(${line})`; + return `${loc}: ${msg}\n`; + } + return `${msg}\n`; +} var LogLevel; (function (LogLevel) { LogLevel["DEBUG"] = "debug"; @@ -4270,22 +4955,16 @@ var LogLevel; })(LogLevel || (exports.LogLevel = LogLevel = {})); class Logger { logLevel = LogLevel.INFO; - _ci; + _provider; static instance = new Logger(); constructor() { + this._provider = process.env.GITHUB_ACTIONS === 'true' + ? new github_actions_ci_1.GitHubActionsLoggerProvider() + : new logger_provider_1.LocalCliLoggerProvider(); if (process.env.GITHUB_ACTIONS === 'true') { - this._ci = 'GITHUB_ACTIONS'; this.logLevel = process.env.ACTIONS_STEP_DEBUG === 'true' ? LogLevel.DEBUG : LogLevel.CI; } } - printLine(message, lineColor, optionalParams = []) { - if (lineColor && lineColor.length > 0) { - process.stdout.write(`${lineColor}${message}\x1b[0m\n`, ...optionalParams); - } - else { - process.stdout.write(`${message}\n`, ...optionalParams); - } - } /** * Logs a message to the console. * @param level The log level for this message. @@ -4294,78 +4973,24 @@ class Logger { */ log(level, message, optionalParams = []) { if (this.shouldLog(level)) { - switch (this._ci) { - case 'GITHUB_ACTIONS': { - switch (level) { - case LogLevel.DEBUG: { - message.toString().split('\n').forEach((line) => { - process.stdout.write(`::debug::${line}\n`, ...optionalParams); - }); - break; - } - case LogLevel.CI: - case LogLevel.INFO: { - process.stdout.write(`${message}\n`, ...optionalParams); - break; - } - default: { - process.stdout.write(`::${level}::${message}\n`, ...optionalParams); - break; - } - } - break; - } - default: { - const stringColor = { - [LogLevel.DEBUG]: '\x1b[35m', // Purple - [LogLevel.INFO]: undefined, // No color / White - [LogLevel.CI]: undefined, // No color / White - [LogLevel.UTP]: undefined, // No color / White - [LogLevel.WARN]: '\x1b[33m', // Yellow - [LogLevel.ERROR]: '\x1b[31m', // Red - }[level] || undefined; // Default to no color / White - this.printLine(message, stringColor, optionalParams); - break; - } - } + this._provider.log(level, message, optionalParams); } } /** * Starts a log group. In CI environments that support grouping, this will create a collapsible group. */ startGroup(message, optionalParams = [], logLevel = LogLevel.INFO) { - switch (this._ci) { - case 'GITHUB_ACTIONS': { - // if there is newline in message, only use the first line for group title - // then print the rest of the lines inside the group in cyan color - const firstLine = message.toString().split('\n')[0]; - const restLines = message.toString().split('\n').slice(1); - process.stdout.write(`::group::${firstLine}\n`, ...optionalParams); - restLines.forEach(line => { - this.printLine(line, '\x1b[36m', ...optionalParams); - }); - break; - } - default: { - // No grouping in standard console - this.log(logLevel, message, optionalParams); - break; - } + if (this._provider.isCi) { + this._provider.startGroup(message, optionalParams); + return; } + this.log(logLevel, message, optionalParams); } /** * Ends a log group. In CI environments that support grouping, this will end the current group. */ endGroup() { - switch (this._ci) { - case 'GITHUB_ACTIONS': { - process.stdout.write(`::endgroup::\n`); - break; - } - default: { - break; // No grouping in standard console - } - } + this._provider.endGroup(); } /** * Logs a message with CI level. @@ -4399,59 +5024,37 @@ class Logger { * @param title The title of the annotation. */ annotate(logLevel, message, file, line, endLine, column, endColumn, title) { - let annotation = ''; - switch (this._ci) { - case 'GITHUB_ACTIONS': { - const level = { - [LogLevel.CI]: 'notice', - [LogLevel.INFO]: 'notice', - [LogLevel.DEBUG]: 'notice', - [LogLevel.UTP]: 'notice', - [LogLevel.WARN]: 'warning', - [LogLevel.ERROR]: 'error', - }[logLevel] ?? 'notice'; - const parts = []; - const appendPart = (key, value) => { - if (value === undefined || value === null) { - return; - } - const stringValue = value.toString(); - if (stringValue.length === 0) { - return; - } - parts.push(`${key}=${this.escapeGitHubCommandValue(stringValue)}`); - }; - appendPart('file', file); - if (line !== undefined && line > 0) { - appendPart('line', line); - } - if (endLine !== undefined && endLine > 0) { - appendPart('endLine', endLine); - } - if (column !== undefined && column > 0) { - appendPart('col', column); - } - if (endColumn !== undefined && endColumn > 0) { - appendPart('endColumn', endColumn); - } - appendPart('title', title); - const metadata = parts.length > 0 ? ` ${parts.join(',')}` : ''; - annotation = `::${level}${metadata}::${this.escapeGitHubCommandValue(message)}`; - break; - } - } - if (annotation.length > 0) { - process.stdout.write(`${annotation}\n`); - } - else { - this.log(logLevel, message); - } - } - escapeGitHubCommandValue(value) { - return value - .replace(/%/g, '%25') - .replace(/\r/g, '%0D') - .replace(/\n/g, '%0A'); + const level = { + [LogLevel.CI]: 'notice', + [LogLevel.INFO]: 'notice', + [LogLevel.DEBUG]: 'notice', + [LogLevel.UTP]: 'notice', + [LogLevel.WARN]: 'warning', + [LogLevel.ERROR]: 'error', + }[logLevel] ?? 'notice'; + const options = {}; + if (file !== undefined && file !== '') { + options.file = file; + } + if (line !== undefined) { + options.line = line; + } + if (endLine !== undefined) { + options.endLine = endLine; + } + if (column !== undefined) { + options.column = column; + } + if (endColumn !== undefined) { + options.endColumn = endColumn; + } + if (title !== undefined && title !== '') { + options.title = title; + } + const backendLevel = level === 'error' + ? github_actions_ci_1.GitHubAnnotationLevel.Error + : (level === 'warning' ? github_actions_ci_1.GitHubAnnotationLevel.Warning : github_actions_ci_1.GitHubAnnotationLevel.Notice); + this._provider.annotate(backendLevel, message, options); } shouldLog(level) { if (level === LogLevel.CI) { @@ -4465,12 +5068,79 @@ class Logger { * @param message The string to mask. */ CI_mask(message) { - switch (this._ci) { - case 'GITHUB_ACTIONS': { - process.stdout.write(`::add-mask::${message}\n`); - break; + this._provider.mask(message); + } + /** + * Masks a credential value in CI environments before it appears in logs. + * This is a convenience wrapper around CI_mask for credential values. + * @param value The credential value to mask. + */ + maskCredential(value) { + if (value && value.length > 0) { + this.CI_mask(value); + } + } + /** + * Logs command-line options with sensitive information scrubbed. + * Automatically removes passwords, tokens, emails, and other credentials from the output. + * @param options The options object to log (typically from commander.js). + * @param optionalParams Additional parameters to log. + */ + debugOptions(options, ...optionalParams) { + // Avoid expensive scrubbing and stringification when debug logging is disabled. + if (this.logLevel !== LogLevel.DEBUG) { + return; + } + const scrubbed = this.scrubSensitiveData(options); + this.debug(JSON.stringify(scrubbed), ...optionalParams); + } + /** + * List of sensitive option keys that should be scrubbed from debug output. + */ + SENSITIVE_KEYS = [ + 'password', + 'email', + 'serial', + 'token', + 'config', + 'organization', + 'username', + 'servicesConfig', + 'serviceaccountkey', + ]; + /** + * Scrubs sensitive information from an object for safe logging. + * Creates a deep clone of the object and replaces sensitive values with [REDACTED]. + * @param obj The object to scrub (typically command-line options). + * @returns A new object with sensitive values replaced. + */ + scrubSensitiveData(obj) { + if (obj === null || obj === undefined) { + return obj; + } + if (typeof obj !== 'object') { + return obj; + } + if (Array.isArray(obj)) { + return obj.map((item) => this.scrubSensitiveData(item)); + } + const scrubbedObj = {}; + for (const key in obj) { + if (obj.hasOwnProperty(key)) { + const lowerKey = key.toLowerCase(); + const isSensitive = this.SENSITIVE_KEYS.some(sensitiveKey => lowerKey.includes(sensitiveKey.toLowerCase())); + if (isSensitive) { + scrubbedObj[key] = '[REDACTED]'; + } + else if (typeof obj[key] === 'object') { + scrubbedObj[key] = this.scrubSensitiveData(obj[key]); + } + else { + scrubbedObj[key] = obj[key]; + } } } + return scrubbedObj; } /** * Sets an environment variable in CI environments that support it. @@ -4478,44 +5148,182 @@ class Logger { * @param value The value of the environment variable. */ CI_setEnvironmentVariable(name, value) { - switch (this._ci) { - case 'GITHUB_ACTIONS': { - // needs to be appended to the temporary file specified in the GITHUB_ENV environment variable - const githubEnv = process.env.GITHUB_ENV; - // echo "MY_ENV_VAR=myValue" >> $GITHUB_ENV - if (githubEnv) { - fs.appendFileSync(githubEnv, `${name}=${value}\n`, { encoding: 'utf8' }); - } + this._provider.setEnvironmentVariable(name, value); + } + CI_setOutput(name, value) { + this._provider.setOutput(name, value); + } + static formatDurationMs(ms) { + if (ms === undefined || !Number.isFinite(ms)) { + return '-'; + } + if (ms < 1000) { + return `${Math.round(ms)}ms`; + } + return `${(ms / 1000).toFixed(1)}s`; + } + static truncateStr(s, max) { + return s.length <= max ? s : s.slice(0, max) + '…'; + } + static truncateSummaryToByteLimit(summary, byteLimit) { + const footer = `\n***Summary truncated due to size limits.***\n`; + const footerSize = Buffer.byteLength(footer, 'utf8'); + const lines = summary.split('\n'); + let rebuilt = ''; + for (const line of lines) { + const nextSize = Buffer.byteLength(rebuilt + line + '\n', 'utf8') + footerSize; + if (nextSize > byteLimit) { break; } + rebuilt += `${line}\n`; } + return rebuilt + footer; } - CI_setOutput(name, value) { - switch (this._ci) { - case 'GITHUB_ACTIONS': { - // needs to be appended to the temporary file specified in the GITHUB_OUTPUT environment variable - const githubOutput = process.env.GITHUB_OUTPUT; - // echo "myOutput=myValue" >> $GITHUB_OUTPUT - if (githubOutput) { - fs.appendFileSync(githubOutput, `${name}=${value}\n`, { encoding: 'utf8' }); - } + /** + * Returns the markdown byte limit for a given output target. + * Workflow summary may be backend constrained; stdout is intentionally uncapped. + */ + getMarkdownByteLimit(target) { + return this._provider.getMarkdownByteLimit(target); + } + CI_appendWorkflowSummary(name, telemetry, options) { + if (telemetry.length === 0) { + return; + } + if (this.getMarkdownByteLimit('workflow-summary') === Number.POSITIVE_INFINITY) { + return; + } + const excludedTypes = new Set(['MemoryLeaks', 'MemoryLeak']); + const filtered = telemetry.filter(entry => !excludedTypes.has(entry.type || '')); + if (filtered.length === 0) { + return; + } + const completedActions = filtered.filter(e => e.type === 'Action' && e.phase === 'End'); + const testResults = collectTestResults(filtered); + const additional = options?.additionalLogEntries ?? []; + const merged = mergeLogEntriesPreferringSeverity([ + ...buildMergedLogList(filtered), + ...additional.filter(e => e.type === 'LogEntry' || e.type === 'Compiler'), + ]); + const pathFiltered = filterMergedByPath(merged, options); + const summaryLogs = filterNoiseFromSummaryLogEntries(pathFiltered); + const bySeverity = groupBySeverity(summaryLogs); + const limit = this.getMarkdownByteLimit('workflow-summary'); + const builders = [ + () => this.buildSummaryTimelineAndMergedLog(name, completedActions, bySeverity, testResults, limit), + () => this.buildSummaryCollapsibleWithMergedLog(name, completedActions, bySeverity, testResults, limit), + () => this.buildSummaryTimelineAndCounts(name, completedActions, summaryLogs.length, testResults, limit), + ]; + let summary = ''; + for (const build of builders) { + summary = build(); + if (Buffer.byteLength(summary, 'utf8') <= limit) { break; } } + if (Buffer.byteLength(summary, 'utf8') > limit) { + summary = Logger.truncateSummaryToByteLimit(summary, limit); + } + this._provider.appendStepSummary(summary); } - CI_appendWorkflowSummary(telemetry) { - switch (this._ci) { - case 'GITHUB_ACTIONS': { - const githubSummary = process.env.GITHUB_STEP_SUMMARY; - if (githubSummary) { - let table = `| Key | Value |\n| --- | ----- |\n`; - telemetry.forEach(item => { - table += `| ${item.key} | ${item.value} |\n`; - }); - fs.appendFileSync(githubSummary, table, { encoding: 'utf8' }); - } - } + /** + * Builds summary: stats + action table + unit-test block + severity foldouts. + */ + buildSummaryTimelineAndMergedLog(name, completedActions, bySeverity, testResults, byteLimit) { + let out = `## ${name} Summary\n\n`; + const totalDurationMs = completedActions.reduce((sum, a) => sum + (a.duration ?? (a.durationMicroseconds != null ? a.durationMicroseconds / 1000 : 0)), 0); + const totalSec = totalDurationMs / 1000; + const totalStr = totalSec >= 60 ? `${Math.round(totalSec / 60)}m ${Math.round(totalSec % 60)}s` : `${totalSec.toFixed(1)}s`; + out += `Errors: ${bySeverity.errorCritical.length}\n`; + out += `Warnings: ${bySeverity.warning.length}\n`; + out += `Total duration: ${totalStr}\n`; + out += `Actions: ${completedActions.length}\n`; + if (testResults.length > 0) { + out += `Tests: ${testResults.length}\n`; + } + out += '\n'; + if (completedActions.length > 0) { + const remaining = byteLimit - Buffer.byteLength(out, 'utf8'); + out += renderBuildActionsFoldoutMarkdown(completedActions, remaining); + } + if (testResults.length > 0) { + const remaining = byteLimit - Buffer.byteLength(out, 'utf8'); + out += buildUnitTestJobSummaryMarkdown(testResults, remaining, ''); + } + const limit = byteLimit; + const appendFoldout = (title, entries, dropSuffix, openByDefault) => { + if (entries.length === 0) + return; + const openAttr = openByDefault ? ' open' : ''; + out += `${title} (${entries.length})\n\n`; + out += '```text\n'; + const appended = appendWorkflowSummaryLogLines(out, entries, limit); + out = appended.out; + if (appended.omitted > 0) { + out += `... and ${appended.omitted} more ${dropSuffix}\n`; + } + out += '```\n\n'; + out += `\n\n`; + }; + appendFoldout('Error', bySeverity.errorCritical, '(see annotations).', true); + appendFoldout('Warning', bySeverity.warning, '(truncated; see full log).'); + appendFoldout('Info', bySeverity.info, '(truncated; see full log).'); + return out; + } + /** + * Builds summary with timeline in a
and merged log foldouts by severity. + * Used when primary builder would exceed size limit. + */ + buildSummaryCollapsibleWithMergedLog(name, completedActions, bySeverity, testResults, byteLimit) { + let out = `## ${name} Summary\n\n`; + if (completedActions.length > 0) { + const remaining = byteLimit - Buffer.byteLength(out, 'utf8'); + out += renderBuildActionsFoldoutMarkdown(completedActions, remaining); + } + if (testResults.length > 0) { + const remaining = byteLimit - Buffer.byteLength(out, 'utf8'); + out += buildUnitTestJobSummaryMarkdown(testResults, remaining, ''); + } + const limit = byteLimit; + const appendFoldout = (title, entries, dropSuffix, openByDefault) => { + if (entries.length === 0) + return; + const openAttr = openByDefault ? ' open' : ''; + out += `${title} (${entries.length})\n\n`; + out += '```text\n'; + const appended = appendWorkflowSummaryLogLines(out, entries, limit); + out = appended.out; + if (appended.omitted > 0) + out += `... and ${appended.omitted} more ${dropSuffix}\n`; + out += '```\n\n'; + out += `
\n\n`; + }; + appendFoldout('Error', bySeverity.errorCritical, '(see annotations).', true); + appendFoldout('Warning', bySeverity.warning, '(truncated; see full log).'); + appendFoldout('Info', bySeverity.info, '(truncated; see full log).'); + return out; + } + /** + * Fallback: list timeline (when actions exist) + unit-test block (when present) + compact count lines. + * Used when even collapsible summary would exceed 1 MB. + */ + buildSummaryTimelineAndCounts(name, completedActions, logCount, testResults, byteLimit) { + let out = `## ${name} Summary\n\n`; + if (completedActions.length > 0) { + const remaining = byteLimit - Buffer.byteLength(out, 'utf8'); + out += renderBuildActionsFoldoutMarkdown(completedActions, remaining); + } + if (testResults.length > 0) { + const remaining = byteLimit - Buffer.byteLength(out, 'utf8'); + out += buildUnitTestJobSummaryMarkdown(testResults, remaining, ''); } + out += `Log entries: ${logCount}\n`; + out += `Actions: ${completedActions.length}\n`; + if (testResults.length > 0) { + out += `Tests: ${testResults.length}\n`; + } + out += `\nSee annotations for details.\n`; + return out; } } exports.Logger = Logger; @@ -4564,6 +5372,7 @@ var __importStar = (this && this.__importStar) || (function () { Object.defineProperty(exports, "__esModule", ({ value: true })); exports.UnityEditor = void 0; const fs = __importStar(__nccwpck_require__(7147)); +const os = __importStar(__nccwpck_require__(2037)); const path = __importStar(__nccwpck_require__(1017)); const logging_1 = __nccwpck_require__(4486); const unity_version_1 = __nccwpck_require__(3331); @@ -4710,6 +5519,28 @@ class UnityEditor { this.logger.debug(`Found ${templates.length} templates:\n${templates.map(t => ` - ${t}`).join('\n')}`); return templates; } + /** + * Scrubs sensitive command-line arguments for safe logging. + * Replaces values for sensitive flags like -username, -password, etc. with [REDACTED]. + * @param args The command-line arguments array. + * @returns A new array with sensitive values redacted. + */ + scrubSensitiveArgs(args) { + const sensitiveFlags = ['-username', '-password', '-cloudOrganization', '-serial']; + const scrubbedArgs = []; + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (!arg) + continue; + scrubbedArgs.push(arg); + // If this is a sensitive flag and the next item is its value + if (sensitiveFlags.includes(arg) && i + 1 < args.length) { + scrubbedArgs.push('[REDACTED]'); + i++; // Skip the next item (the actual value) since we've already added [REDACTED] + } + } + return scrubbedArgs; + } /** * Run the Unity Editor with the specified command line arguments. * @param command The command containing arguments and optional project path. @@ -4775,7 +5606,9 @@ class UnityEditor { } const logPath = (0, utilities_1.GetArgumentValueAsString)('-logFile', command.args); logTail = (0, unity_logging_1.TailLogFile)(logPath, command.projectPath); - const commandStr = `\x1b[34m${this.editorPath} ${command.args.join(' ')}\x1b[0m`; + // Scrub sensitive arguments before logging + const scrubbedArgs = this.scrubSensitiveArgs(command.args); + const commandStr = `\x1b[34m${this.editorPath} ${scrubbedArgs.join(' ')}\x1b[0m`; this.logger.startGroup(commandStr); if (this.version.isLegacy() && process.platform === 'darwin' && process.arch === 'arm64') { throw new Error(`Cannot execute Unity ${this.version.toString()} on Apple Silicon Macs.`); @@ -4786,6 +5619,7 @@ class UnityEditor { const baseEditorEnv = { ...process.env, UNITY_THISISABUILDMACHINE: '1', + DISABLE_EMBEDDED_BUILD_PIPELINE_PLUGIN_LOGGING: '1', ...(linuxEnvOverrides ?? {}) }; if (process.platform === 'linux' && @@ -4884,33 +5718,66 @@ class UnityEditor { const timestamp = new Date().toISOString().replace(/[-:]/g, ``).replace(/\..+/, ``); return path.join(logsDir, `${prefix ? prefix + '-' : ''}Unity-${timestamp}.log`); } + /** + * Resolves a writable Pulse/XDG runtime directory. CI runners often lack systemd-logind's `/run/user/$UID` + * (Pulse then fails with "Failed to create secure directory (.../pulse)"). + */ + async resolveLinuxXdgRuntimeDir() { + const fromEnv = process.env.XDG_RUNTIME_DIR?.trim(); + if (fromEnv && fromEnv.length > 0) { + try { + await fs.promises.mkdir(fromEnv, { recursive: true, mode: 0o700 }); + } + catch (error) { + this.logger.debug(`Could not mkdir XDG_RUNTIME_DIR (${fromEnv}): ${error}`); + } + try { + await fs.promises.access(fromEnv, fs.constants.W_OK); + return fromEnv; + } + catch { + this.logger.debug(`XDG_RUNTIME_DIR from environment is not usable (${fromEnv}); falling back like unset.`); + } + } + const uid = typeof process.getuid === 'function' ? process.getuid() : 1000; + const systemdUser = `/run/user/${uid}`; + try { + await fs.promises.access(systemdUser, fs.constants.W_OK); + return systemdUser; + } + catch { + this.logger.debug(`Using tmp XDG_RUNTIME_DIR (not using ${systemdUser}: missing or not writable).`); + } + const fallback = path.join(os.tmpdir(), `unity-cli-xdg-runtime-${uid}`); + await fs.promises.mkdir(fallback, { recursive: true, mode: 0o700 }); + return fallback; + } async prepareLinuxAudioEnvironment() { if (process.platform !== 'linux') { return {}; } + const runtimeDir = await this.resolveLinuxXdgRuntimeDir(); const envOverrides = { SDL_AUDIODRIVER: process.env.SDL_AUDIODRIVER || 'dummy', AUDIODRIVER: process.env.AUDIODRIVER || 'dummy', - AUDIODEV: process.env.AUDIODEV || 'null', - ALSA_CARD: process.env.ALSA_CARD || 'Loopback', - PULSE_SINK: process.env.PULSE_SINK || 'unity_dummy' + AUDIODEV: process.env.AUDIODEV?.trim() || 'null', + PULSE_SINK: process.env.PULSE_SINK || 'unity_dummy', + XDG_RUNTIME_DIR: runtimeDir, }; - const defaultRuntimeDir = `/run/user/${typeof process.getuid === 'function' ? process.getuid() : 1000}`; - const runtimeDir = process.env.XDG_RUNTIME_DIR || defaultRuntimeDir; - envOverrides.XDG_RUNTIME_DIR = runtimeDir; - try { - await fs.promises.mkdir(runtimeDir, { recursive: true, mode: 0o700 }); - } - catch (error) { - this.logger.debug(`Failed to ensure XDG_RUNTIME_DIR (${runtimeDir}): ${error}`); - } - await this.tryExec('bash', ['-c', 'pulseaudio --check 2>/dev/null || pulseaudio --start --exit-idle-time=-1 || true']); - await this.tryExec('bash', ['-c', 'command -v pactl >/dev/null 2>&1 && { pactl list short sinks 2>/dev/null | grep -q unity_dummy || pactl load-module module-null-sink sink_name=unity_dummy sink_properties=device.description=UnityCI >/tmp/unity-null-sink.id; } || true']); + const alsaCard = process.env.ALSA_CARD?.trim(); + if (alsaCard && alsaCard.length > 0) { + envOverrides.ALSA_CARD = alsaCard; + } + await this.tryExec('bash', ['-c', 'pulseaudio --check 2>/dev/null || pulseaudio --start --exit-idle-time=-1 || true'], envOverrides); + await this.tryExec('bash', [ + '-c', + 'command -v pactl >/dev/null 2>&1 && { pactl list short sinks 2>/dev/null | grep -q unity_dummy || pactl load-module module-null-sink sink_name=unity_dummy sink_properties=device.description=UnityCI >/tmp/unity-null-sink.id; } || true', + ], envOverrides); return envOverrides; } - async tryExec(command, args) { + async tryExec(command, args, env) { try { - await (0, utilities_1.Exec)(command, args, { silent: true, showCommand: false }); + await (0, utilities_1.Exec)(command, args, { silent: true, showCommand: false, env }); } catch (error) { this.logger.debug(`Skipped helper command "${command} ${args.join(' ')}": ${error}`); @@ -5028,12 +5895,13 @@ var __importStar = (this && this.__importStar) || (function () { }; })(); Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.UnityHub = void 0; +exports.UnityHub = exports.LINUX_HUB_EXECUTABLE_LEGACY = exports.LINUX_HUB_EXECUTABLE_MODERN = void 0; +exports.resolveLinuxHubExecutable = resolveLinuxHubExecutable; const fs = __importStar(__nccwpck_require__(7147)); const os = __importStar(__nccwpck_require__(2037)); const path = __importStar(__nccwpck_require__(1017)); const yaml = __importStar(__nccwpck_require__(4083)); -const asar = __importStar(__nccwpck_require__(5837)); +const asar = __importStar(__nccwpck_require__(9852)); const child_process_1 = __nccwpck_require__(2081); const logging_1 = __nccwpck_require__(4486); const unity_editor_1 = __nccwpck_require__(8944); @@ -5043,6 +5911,113 @@ const utilities_1 = __nccwpck_require__(9746); const unity_releases_api_1 = __nccwpck_require__(7278); /** First Unity Hub line with native Windows ARM64 installers on the public CDN. */ const MIN_NATIVE_WINDOWS_ARM64_HUB_VERSION = (0, semver_1.coerce)('3.17.0'); +/** Allowed characters in a Debian package version (no shell metacharacters). */ +const LINUX_HUB_DEB_VERSION_RE = /^[0-9A-Za-z.+~:-]+$/; +/** Hub 3.20+ Electron Forge deb layout. */ +exports.LINUX_HUB_EXECUTABLE_MODERN = '/usr/lib/unityhub/unityhub'; +/** Hub ≤3.19 fpm / electron-builder layout. */ +exports.LINUX_HUB_EXECUTABLE_LEGACY = '/opt/unityhub/unityhub'; +/** + * Resolves the Unity Hub binary on Linux. + * Prefers UNITY_HUB_PATH, then the Hub 3.20+ path, then the legacy /opt path. + * When neither is present (pre-install), defaults to the modern path. + */ +function resolveLinuxHubExecutable(envPath = process.env.UNITY_HUB_PATH, existsSync = fs.existsSync) { + if (envPath !== undefined && envPath.length > 0) { + return envPath; + } + if (existsSync(exports.LINUX_HUB_EXECUTABLE_MODERN)) { + return exports.LINUX_HUB_EXECUTABLE_MODERN; + } + if (existsSync(exports.LINUX_HUB_EXECUTABLE_LEGACY)) { + return exports.LINUX_HUB_EXECUTABLE_LEGACY; + } + return exports.LINUX_HUB_EXECUTABLE_MODERN; +} +/** + * Fixed bootstrap for Linux Hub apt repo + update index. No user-controlled interpolation (CodeQL). + * Uses DEB822 .sources (Hub 3.20+) and removes legacy .list to avoid duplicate-source warnings. + */ +const LINUX_HUB_LINUX_UPDATE_REPO_BOOTSTRAP = `#!/bin/sh +set -e +wget -qO - https://hub.unity3d.com/linux/keys/public | gpg --dearmor | sudo tee /usr/share/keyrings/Unity_Technologies_ApS.gpg >/dev/null +sudo rm -f /etc/apt/sources.list.d/unityhub.list +sudo tee /etc/apt/sources.list.d/unityhub.sources >/dev/null <<'EOF' +Types: deb +URIs: https://hub.unity3d.com/linux/repos/deb +Suites: stable +Components: main +Signed-By: /usr/share/keyrings/Unity_Technologies_ApS.gpg +EOF +sudo apt-get update --allow-releaseinfo-change +`; +/** + * First phase of fresh Linux Hub install: machine-id, repo keys, jammy mirror, apt-get update. + * No user-controlled interpolation. Uses DEB822 .sources (Hub 3.20+). + */ +const LINUX_HUB_LINUX_INSTALL_BOOTSTRAP = `#!/bin/sh +set -e +dbus-uuidgen >/etc/machine-id && mkdir -p /var/lib/dbus/ && ln -sf /etc/machine-id /var/lib/dbus/machine-id +wget -qO - https://hub.unity3d.com/linux/keys/public | gpg --dearmor | tee /usr/share/keyrings/Unity_Technologies_ApS.gpg >/dev/null +rm -f /etc/apt/sources.list.d/unityhub.list +tee /etc/apt/sources.list.d/unityhub.sources >/dev/null <<'EOF' +Types: deb +URIs: https://hub.unity3d.com/linux/repos/deb +Suites: stable +Components: main +Signed-By: /usr/share/keyrings/Unity_Technologies_ApS.gpg +EOF +echo "deb https://archive.ubuntu.com/ubuntu jammy main universe" | tee /etc/apt/sources.list.d/jammy.list +apt-get update +`; +/** + * Post-install cleanup and xvfb / unity-hub wrapper setup. Runs as root; no user interpolation. + * Wrapper resolves Hub 3.20+ (/usr/lib/unityhub) vs legacy (/opt/unityhub) at runtime so apt + * upgrades that move the binary do not leave a stale path (exit 127). + */ +const LINUX_HUB_LINUX_INSTALL_POST = `#!/bin/sh +set -e +apt-get clean +sed -i 's/^\\(.*DISPLAY=:.*XAUTHORITY=.*\\)\\( "\\$@" \\)2>&1$/\\1\\2/' /usr/bin/xvfb-run +command -v unityhub >/dev/null || { echo "Unity Hub installation failed"; exit 1; } +hubPath=$(readlink -f "$(command -v unityhub)" 2>/dev/null || true) +if [ -z "$hubPath" ] || [ ! -x "$hubPath" ]; then + if [ -x /usr/lib/unityhub/unityhub ]; then + hubPath=/usr/lib/unityhub/unityhub + elif [ -x /opt/unityhub/unityhub ]; then + hubPath=/opt/unityhub/unityhub + else + echo "Failed to install Unity Hub" + exit 1 + fi +fi +tee /usr/bin/unity-hub >/dev/null <<'WRAPPER' +#!/bin/bash +if [ -x /usr/lib/unityhub/unityhub ]; then + hubBin=/usr/lib/unityhub/unityhub +elif [ -x /opt/unityhub/unityhub ]; then + hubBin=/opt/unityhub/unityhub +else + hubBin=$(readlink -f "$(command -v unityhub)" 2>/dev/null || true) +fi +if [ -z "$hubBin" ] || [ ! -x "$hubBin" ]; then + echo "Unity Hub binary not found" >&2 + exit 127 +fi +exec xvfb-run --auto-servernum "$hubBin" "$@" 2>/dev/null +WRAPPER +chmod 777 /usr/bin/unity-hub +chmod -R 777 "$(dirname "$hubPath")" +`; +const LINUX_HUB_LINUX_APT_EXTRAS = [ + 'xvfb', + 'ffmpeg', + 'libgtk2.0-0', + 'libglu1-mesa', + 'libgconf-2-4', + 'libncurses5', + 'pulseaudio', +]; class UnityHub { /** The path to the Unity Hub executable. */ executable; @@ -5079,21 +6054,98 @@ class UnityHub { this.editorFileExtension = '/Unity.app/Contents/MacOS/Unity'; break; case 'linux': - this.executable = process.env.UNITY_HUB_PATH || '/opt/unityhub/unityhub'; - this.rootDirectory = path.join(this.executable, '../'); + this.refreshLinuxHubPaths(); this.editorFileExtension = '/Editor/Unity'; break; default: throw new Error(`Unsupported platform: ${process.platform}`); } } + /** Re-resolve Linux Hub executable + root after install/upgrade (Hub 3.20 moved under /usr/lib). */ + refreshLinuxHubPaths() { + this.executable = resolveLinuxHubExecutable(); + this.rootDirectory = path.join(this.executable, '../'); + } + /** + * Some Hub builds (notably Windows headless) occasionally exit non-zero after streaming usable + * `editors --releases` / `editors -i` data. Tolerate only when the captured output parses the same + * way {@link ListAvailableReleases} / {@link ListInstalledEditors} would (avoids regex false positives). + */ + hubListingExitTolerable(args, hubOutput) { + if (!this.isHubEditorListingArgs(args)) { + return false; + } + if (args.includes('--releases')) { + return this.parseAvailableReleasesFromHubText(hubOutput).length > 0; + } + if (args.includes('-i') || args.includes('--installed')) { + return hubOutput.includes('installed at'); + } + return false; + } + isHubEditorListingArgs(args) { + return args.length > 0 && args[0] === 'editors' && + (args.includes('--releases') || args.includes('-i') || args.includes('--installed')); + } + async delayMs(ms) { + await new Promise((resolve) => setTimeout(resolve, ms)); + } + /** Same parsing rules as {@link ListAvailableReleases}; must stay in sync. */ + parseAvailableReleasesFromHubText(output) { + return output.split('\n') + .map(line => line.trim()) + .map(line => { + const match = line.match(/^(\d{1,4}\.\d+\.\d+[abcfpx]?\d*)/); + return match ? match[1] : undefined; + }) + .filter((line) => !!line && /^\d{1,4}\.\d+\.\d+[abcfpx]?\d*/.test(line)) + .map(line => new unity_version_1.UnityVersion(line)) + .sort((a, b) => unity_version_1.UnityVersion.compare(b, a)); + } + /** Same parsing rules as {@link ListInstalledEditors}; must stay in sync. */ + parseInstalledEditorsFromHubText(output) { + const paths = output.split('\n') + .filter(line => /installed at/.test(line)) + .map(line => line.trim()); + const editors = []; + const pattern = /(?\d+\.\d+\.\d+[abcfpx]?\d*)\s*(?:\((?Apple silicon|Intel)\))?\s*,? installed at (?.*)/; + const matches = paths.map((line) => line.match(pattern)).filter(match => match && match.groups); + if (paths.length !== matches.length) { + throw new Error(`Failed to parse all installed Unity Editors!\n > paths: ${JSON.stringify(paths)}\n > matches: ${JSON.stringify(matches)}`); + } + for (const match of matches) { + if (match && match.groups && match.groups.version && match.groups.editorPath) { + const version = new unity_version_1.UnityVersion(match.groups.version, null, match.groups.arch === 'Apple silicon' ? 'ARM64' : match.groups.arch === 'Intel' ? 'X86_64' : undefined); + editors.push(new unity_editor_1.UnityEditor(path.normalize(match.groups.editorPath), version)); + } + } + editors.sort((a, b) => { + if (!a.version && !b.version) { + return 0; + } + if (!a.version) { + return 1; + } + if (!b.version) { + return -1; + } + return unity_version_1.UnityVersion.compare(b.version, a.version); + }); + return editors; + } /** * Executes the Unity Hub command with the specified arguments. * @param args Arguments to pass to the Unity Hub executable. - * @param silent If true, suppresses output logging. + * @param options Logging and spawn options for this invocation. * @returns The output from the command. */ async Exec(args, options = { silent: this.logger.logLevel > logging_1.LogLevel.CI, showCommand: this.logger.logLevel <= logging_1.LogLevel.CI }) { + return this.execImpl(args, options, 0); + } + /** + * @param listingRetryDepth 0 on first attempt; 1 after one listing-only retry (flaky Hub exits on Windows CI). + */ + async execImpl(args, options, listingRetryDepth) { let output = ''; let exitCode = 0; const filteredArgs = args.filter(arg => arg !== '--headless' && arg !== '--'); @@ -5134,7 +6186,8 @@ class UnityHub { 'Completed with errors.' ]; const child = (0, child_process_1.spawn)(executable, execArgs, { - stdio: ['ignore', 'pipe', 'pipe'] + stdio: ['ignore', 'pipe', 'pipe'], + ...(process.platform === 'win32' ? { windowsHide: true } : {}), }); const sigintHandler = () => child.kill('SIGINT'); const sigtermHandler = () => child.kill('SIGTERM'); @@ -5262,17 +6315,27 @@ class UnityHub { if (match || retryConditions.some(s => output.includes(s))) { this.logger.warn(`Install failed, retrying...`); - return await this.Exec(args); + return await this.execImpl(args, options, 0); } if (exitCode > 0) { - const error = output.match(/Error(?: given)?:\s*(.+)/); - const errorMessage = error && error[1] ? error[1] : 'Unknown Error'; - switch (errorMessage) { - case 'No modules found to install.': - break; - default: - this.logger.debug(output); - throw new Error(`Failed to execute Unity Hub (exit code: ${exitCode}) ${errorMessage}`); + if (this.hubListingExitTolerable(args, output)) { + this.logger.warn(`Unity Hub exited with code ${exitCode} but produced usable listing output; continuing.`); + } + else { + const error = output.match(/Error(?: given)?:\s*(.+)/); + const errorMessage = error && error[1] ? error[1] : 'Unknown Error'; + switch (errorMessage) { + case 'No modules found to install.': + break; + default: + if (this.isHubEditorListingArgs(args) && listingRetryDepth < 1) { + this.logger.warn(`Unity Hub listing command failed (exit code ${exitCode}); retrying once after 2s...`); + await this.delayMs(2000); + return await this.execImpl(args, options, listingRetryDepth + 1); + } + this.logger.debug(output); + throw new Error(`Failed to execute Unity Hub (exit code: ${exitCode}) ${errorMessage}`); + } } } output = output.split('\n') @@ -5357,12 +6420,14 @@ class UnityHub { await this.installHub(version); } else if (process.platform === 'linux') { - await (0, utilities_1.Exec)('sudo', ['sh', '-c', `#!/bin/bash -set -e -wget -qO - https://hub.unity3d.com/linux/keys/public | gpg --dearmor | sudo tee /usr/share/keyrings/Unity_Technologies_ApS.gpg >/dev/null -sudo sh -c 'echo "deb [signed-by=/usr/share/keyrings/Unity_Technologies_ApS.gpg] https://hub.unity3d.com/linux/repos/deb stable main" > /etc/apt/sources.list.d/unityhub.list' -sudo apt-get update --allow-releaseinfo-change -sudo apt-get install -y --no-install-recommends --only-upgrade unityhub${version ? '=' + version : ''}`]); + const hubPkg = this.unityHubAptPackageSpec(version); + const linuxExecOpts = { silent: true, showCommand: true }; + await (0, utilities_1.Exec)('sudo', ['sh', '-c', LINUX_HUB_LINUX_UPDATE_REPO_BOOTSTRAP], linuxExecOpts); + await (0, utilities_1.Exec)('sudo', ['apt-get', 'install', '-y', '--no-install-recommends', '--only-upgrade', hubPkg], linuxExecOpts); + // Refresh xvfb wrapper after upgrades that move /opt → /usr/lib (Hub 3.20+). + await (0, utilities_1.Exec)('sudo', ['sh', '-c', LINUX_HUB_LINUX_INSTALL_POST], linuxExecOpts); + this.refreshLinuxHubPaths(); + this.logger.info(`Unity Hub updated successfully.`); } else { throw new Error(`Unsupported platform: ${process.platform}`); @@ -5372,9 +6437,40 @@ sudo apt-get install -y --no-install-recommends --only-upgrade unityhub${version this.logger.info(`Unity Hub is already installed and up to date.`); } } + if (process.platform === 'linux') { + this.refreshLinuxHubPaths(); + } await fs.promises.access(this.executable, fs.constants.X_OK); return this.executable; } + /** + * APT package spec for unityhub (e.g. `unityhub` or `unityhub=3.6.0`). Validated; passed as argv, not shell-embedded. + */ + unityHubAptPackageSpec(version) { + if (version === undefined || version === null) { + return 'unityhub'; + } + if (typeof version === 'object' && 'version' in version) { + const deb = version.version; + if (!LINUX_HUB_DEB_VERSION_RE.test(deb)) { + throw new Error(`Invalid Unity Hub apt version: ${deb}`); + } + return `unityhub=${deb}`; + } + const raw = String(version).trim(); + if (raw.length === 0) { + return 'unityhub'; + } + const pinned = (0, semver_1.coerce)(raw); + if (!pinned || !(0, semver_1.valid)(pinned)) { + throw new Error(`Invalid Unity Hub version for apt: ${raw}`); + } + const deb = pinned.version; + if (!LINUX_HUB_DEB_VERSION_RE.test(deb)) { + throw new Error(`Invalid Unity Hub apt version: ${deb}`); + } + return `unityhub=${deb}`; + } async installHub(version) { this.logger.ci(`Installing Unity Hub${version ? ' ' + version : ''}...`); if (!version) { @@ -5461,35 +6557,12 @@ sudo apt-get install -y --no-install-recommends --only-upgrade unityhub${version break; } case 'linux': { - await (0, utilities_1.Exec)('sudo', ['sh', '-c', `#!/bin/bash -set -e -dbus-uuidgen >/etc/machine-id && mkdir -p /var/lib/dbus/ && ln -sf /etc/machine-id /var/lib/dbus/machine-id -wget -qO - https://hub.unity3d.com/linux/keys/public | gpg --dearmor | tee /usr/share/keyrings/Unity_Technologies_ApS.gpg >/dev/null -echo "deb [signed-by=/usr/share/keyrings/Unity_Technologies_ApS.gpg] https://hub.unity3d.com/linux/repos/deb stable main" > /etc/apt/sources.list.d/unityhub.list -echo "deb https://archive.ubuntu.com/ubuntu jammy main universe" | tee /etc/apt/sources.list.d/jammy.list -apt-get update -apt-get install -y --no-install-recommends \\ - unityhub${version ? '=' + version : ''} \\ - xvfb \\ - ffmpeg \\ - libgtk2.0-0 \\ - libglu1-mesa \\ - libgconf-2-4 \\ - libncurses5 \\ - pulseaudio -apt-get clean -sed -i 's/^\\(.*DISPLAY=:.*XAUTHORITY=.*\\)\\( "\\$@" \\)2>&1$/\\1\\2/' /usr/bin/xvfb-run -printf '#!/bin/bash\nxvfb-run --auto-servernum /opt/unityhub/unityhub "$@" 2>/dev/null' | tee /usr/bin/unity-hub >/dev/null -chmod 777 /usr/bin/unity-hub -which unityhub || { echo "Unity Hub installation failed"; exit 1; } -hubPath=$(which unityhub) - -if [ -z "$hubPath" ]; then - echo "Failed to install Unity Hub" - exit 1 -fi - -chmod -R 777 "$hubPath"`]); + const hubPkg = this.unityHubAptPackageSpec(version); + const linuxExecOpts = { silent: true, showCommand: true }; + await (0, utilities_1.Exec)('sudo', ['sh', '-c', LINUX_HUB_LINUX_INSTALL_BOOTSTRAP], linuxExecOpts); + await (0, utilities_1.Exec)('sudo', ['apt-get', 'install', '-y', '--no-install-recommends', hubPkg, ...LINUX_HUB_LINUX_APT_EXTRAS], linuxExecOpts); + await (0, utilities_1.Exec)('sudo', ['sh', '-c', LINUX_HUB_LINUX_INSTALL_POST], linuxExecOpts); + this.refreshLinuxHubPaths(); break; } default: @@ -5546,8 +6619,7 @@ chmod -R 777 "$hubPath"`]); default: throw new Error(`Unsupported platform: ${process.platform}`); } - const response = await fetch(url); - const data = await response.text(); + const data = await (0, utilities_1.HttpsGetText)(url); const parsed = yaml.parse(data); const version = (0, semver_1.coerce)(parsed.version); if (!version || !(0, semver_1.valid)(version)) { @@ -5597,27 +6669,43 @@ chmod -R 777 "$hubPath"`]); this.logger.ci(`Getting release info for Unity ${unityVersion.toString()}...`); let resolvedVersion = unityVersion; if (!resolvedVersion.isLegacy()) { - try { - if (!resolvedVersion.isFullyQualified()) { + // Hub list is a fast path only. Misses must fall through to the Releases API — + // do not fail-closed until both Hub match and API resolution have failed. + if (!resolvedVersion.isFullyQualified()) { + try { const releases = await this.ListAvailableReleases(); logging_1.Logger.instance.debug(`Found ${releases.length} available Unity releases, searching channels: ${channels.join(', ')}`); resolvedVersion = resolvedVersion.findMatch(releases, channels); } - if (!resolvedVersion?.changeset) { - const unityReleaseInfo = await this.GetEditorReleaseInfo(resolvedVersion); - resolvedVersion = new unity_version_1.UnityVersion(unityReleaseInfo.version, unityReleaseInfo.shortRevision, resolvedVersion.architecture); + catch (hubMatchError) { + this.logger.debug(`No Hub list match for ${resolvedVersion.toString()} (channels: ${channels.join(', ')}); trying Releases API...\n${hubMatchError}`); } } - catch (error) { - this.logger.warn(`Failed to get Unity release info for ${resolvedVersion.toString()}! falling back to legacy search...\n${error}`); + if (!resolvedVersion.changeset) { try { - resolvedVersion = await this.fallbackVersionLookup(resolvedVersion); + const unityReleaseInfo = await this.GetEditorReleaseInfo(resolvedVersion, channels); + resolvedVersion = new unity_version_1.UnityVersion(unityReleaseInfo.version, unityReleaseInfo.shortRevision, resolvedVersion.architecture); } - catch (fallbackError) { - this.logger.warn(`Failed to lookup changeset for Unity ${resolvedVersion.toString()}!\n${fallbackError}`); + catch (error) { + // Fail closed for partial versions: never Hub-install "6000.6" and hope it picks a beta. + if (!resolvedVersion.isFullyQualified()) { + const msg = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to resolve Unity ${unityVersion.toString()} for channel(s) [${channels.join(', ')}]: ${msg}`); + } + this.logger.warn(`Failed to get Unity release info for ${resolvedVersion.toString()}! falling back to legacy search...\n${error}`); + try { + resolvedVersion = await this.fallbackVersionLookup(resolvedVersion); + } + catch (fallbackError) { + this.logger.warn(`Failed to lookup changeset for Unity ${resolvedVersion.toString()}!\n${fallbackError}`); + } } } } + if (!resolvedVersion.isLegacy() && !resolvedVersion.isFullyQualified()) { + throw new Error(`Refusing to install non-fully-qualified Unity version ${resolvedVersion.toString()} without a resolved release. ` + + `Use a fully-qualified version or --channel matching an available stream.`); + } const allowPartialMatches = !resolvedVersion.isFullyQualified(); let editorPath = await this.checkInstalledEditors(resolvedVersion, false, undefined, allowPartialMatches); unityVersion = resolvedVersion; @@ -5627,7 +6715,8 @@ chmod -R 777 "$hubPath"`]); installDir = await this.installUnity(unityVersion, modules); } catch (error) { - if (retryErrorMessages.some(msg => error.message.includes(msg))) { + const errMessage = error instanceof Error ? error.message : String(error); + if (retryErrorMessages.some((msg) => errMessage.includes(msg))) { if (editorPath) { await (0, utilities_1.DeleteDirectory)(editorPath); } @@ -5667,7 +6756,8 @@ chmod -R 777 "$hubPath"`]); } } catch (error) { - if (error.message.includes(`No modules found`)) { + const errMessage = error instanceof Error ? error.message : String(error); + if (errMessage.includes(`No modules found`)) { await (0, utilities_1.DeleteDirectory)(editorPath); await this.GetEditor(unityVersion, modules); } @@ -5683,35 +6773,7 @@ chmod -R 777 "$hubPath"`]); */ async ListInstalledEditors() { const output = await this.Exec(['editors', '-i']); - const paths = output.split('\n') - .filter(line => /installed at/.test(line)) - .map(line => line.trim()); - const editors = []; - const pattern = /(?\d+\.\d+\.\d+[abcfpx]?\d*)\s*(?:\((?Apple silicon|Intel)\))?\s*,? installed at (?.*)/; - const matches = paths.map(path => path.match(pattern)).filter(match => match && match.groups); - if (paths.length !== matches.length) { - throw new Error(`Failed to parse all installed Unity Editors!\n > paths: ${JSON.stringify(paths)}\n > matches: ${JSON.stringify(matches)}`); - } - for (const match of matches) { - if (match && match.groups && match.groups.version && match.groups.editorPath) { - const version = new unity_version_1.UnityVersion(match.groups.version, null, match.groups.arch === 'Apple silicon' ? 'ARM64' : match.groups.arch === 'Intel' ? 'X86_64' : undefined); - editors.push(new unity_editor_1.UnityEditor(path.normalize(match.groups.editorPath), version)); - } - } - // Sort editors descending by UnityVersion so callers receive newest matches first - editors.sort((a, b) => { - if (!a.version && !b.version) { - return 0; - } - if (!a.version) { - return 1; - } - if (!b.version) { - return -1; - } - return unity_version_1.UnityVersion.compare(b.version, a.version); - }); - return editors; + return this.parseInstalledEditorsFromHubText(output); } /** * Lists the available Unity releases. @@ -5719,16 +6781,7 @@ chmod -R 777 "$hubPath"`]); */ async ListAvailableReleases() { const output = await this.Exec(['editors', '--releases']); - // filter out version lines only 2021.3.45f2 (may include installed path following version) - return output.split('\n') - .map(line => line.trim()) - .map(line => { - const match = line.match(/^(\d{1,4}\.\d+\.\d+[abcfpx]?\d*)/); - return match ? match[1] : undefined; - }) - .filter((line) => !!line && /^\d{1,4}\.\d+\.\d+[abcfpx]?\d*/.test(line)) - .map(line => new unity_version_1.UnityVersion(line)) - .sort((a, b) => unity_version_1.UnityVersion.compare(b, a)); // Sort descending by version + return this.parseAvailableReleasesFromHubText(output); } async checkInstalledEditors(unityVersion, failOnEmpty, installDir = undefined, allowPartialMatches = true) { let editorPath = undefined; @@ -5832,9 +6885,10 @@ done * Gets the specified Unity release info from the Unity Releases API. * Supports querying by exact version or by prefix (e.g., "2020", "2020.1", "2021.x", "2021.3.x"). * @param unityVersion The Unity version to get the release info for. + * @param channels Letter channels to accept (`f`, `p`, `b`, `a`, `x`). Default stable-only. * @returns The Unity release info. */ - async GetEditorReleaseInfo(unityVersion) { + async GetEditorReleaseInfo(unityVersion, channels = ['f']) { // Prefer querying the releases API with the exact fully-qualified Unity version (e.g., 2022.3.10f1). // If we don't have a fully-qualified version, use the most specific prefix available: // - "YYYY.M" when provided (e.g., 6000.1) @@ -5854,6 +6908,7 @@ done } } const releasesClient = new unity_releases_api_1.UnityReleasesClient(); + const channelSet = new Set(channels.map(c => c.toLowerCase())); function getPlatform() { switch (process.platform) { case 'darwin': @@ -5866,6 +6921,10 @@ done throw new Error(`Unsupported platform: ${process.platform}`); } } + function releaseChannelLetter(releaseVersion) { + const m = /^(\d{1,4})\.(\d+)\.(\d+)([abcfpx])(\d+)$/.exec(releaseVersion); + return m?.[4]; + } const request = { url: '/unity/editor/release/v1/releases', query: { @@ -5885,16 +6944,29 @@ done if (!data || !data.results || data.results.length === 0) { throw new Error(`No Unity releases found for version: ${version}`); } - // Filter to stable 'f' releases only unless the user explicitly asked for a pre-release - const isExplicitPrerelease = /[abcpx]$/.test(unityVersion.version) || /[abcpx]/.test(unityVersion.version); const releases = (data.results || []) - .filter(release => isExplicitPrerelease || release.version.includes('f')) + .filter((release) => { + const v = release.version; + if (v == null || v === '') { + return false; + } + // Exact FQ request: accept that row regardless of channel filter. + if (fullUnityVersionPattern.test(unityVersion.version) && v === unityVersion.version) { + return true; + } + const letter = releaseChannelLetter(v); + return letter != null && channelSet.has(letter); + }) .map(release => ({ unityRelease: release, unityVersion: new unity_version_1.UnityVersion(release.version, release.shortRevision, unityVersion.architecture) })); if (releases.length === 0) { - throw new Error(`No suitable Unity releases (stable) found for version: ${version}`); + const channelList = [...channelSet].join(','); + throw new Error(`No suitable Unity releases (channels: ${channelList}) found for version: ${version}` + + (channelSet.has('f') && channelSet.size === 1 + ? `. No stable (f) release for ${version}; use --channel b/a or a fully-qualified version.` + : '')); } releases.sort((a, b) => unity_version_1.UnityVersion.compare(b.unityVersion, a.unityVersion)); logging_1.Logger.instance.debug(`Found ${releases.length} matching Unity releases for version: ${version}`); @@ -6257,6 +7329,10 @@ var __importStar = (this && this.__importStar) || (function () { Object.defineProperty(exports, "__esModule", ({ value: true })); exports.ActionTableRenderer = void 0; exports.sanitizeTelemetryJson = sanitizeTelemetryJson; +exports.formatUtpUnrecognizedTopLevelPropertiesMessage = formatUtpUnrecognizedTopLevelPropertiesMessage; +exports.describeUtpForUtpLogLevel = describeUtpForUtpLogLevel; +exports.normalizeAnnotationPath = normalizeAnnotationPath; +exports.isFileUnderProjectPath = isFileUnderProjectPath; exports.stringDisplayWidth = stringDisplayWidth; exports.formatActionTimelineTable = formatActionTimelineTable; exports.TailLogFile = TailLogFile; @@ -6264,9 +7340,10 @@ const fs = __importStar(__nccwpck_require__(7147)); const path = __importStar(__nccwpck_require__(1017)); const logging_1 = __nccwpck_require__(4486); const utilities_1 = __nccwpck_require__(9746); -const utp_1 = __nccwpck_require__(881); -// Detects GitHub-style annotation markers to avoid emitting duplicates -const githubAnnotationPrefixRegex = /\n::[a-z]+::/i; +const utp_1 = __nccwpck_require__(6282); +const utp_benign_1 = __nccwpck_require__(6239); +// Detects workflow command markers to avoid emitting duplicate annotations +const annotationCommandPrefixRegex = /\n::[a-z]+::/i; // Matches ANSI escape sequences (CSI and single-character) const ansiEscapeSequenceRegex = /\u001b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g; const TIMELINE_HEADING = '🔨 Unity Build Timeline'; @@ -6285,12 +7362,48 @@ function sanitizeTelemetryJson(raw) { } return sanitized; } +/** Builds the warning when a `##utp:` payload includes unrecognized root properties. Exported for tests. */ +function formatUtpUnrecognizedTopLevelPropertiesMessage(unknownTopLevelKeys, fullTelemetryLine) { + return `UTP entry contains unrecognized top-level properties: ${unknownTopLevelKeys.join(', ')}\nFull line: ${fullTelemetryLine}`; +} +/** + * Single-line debug text for `--log-level UTP` for telemetry types that do not use the action / memory / player-build tables. + * Returns `undefined` when the type should fall back to unknown-type handling (warn + raw JSON). + */ +function describeUtpForUtpLogLevel(utp) { + switch (utp.type) { + case 'Compiler': + case 'LogEntry': { + const u = utp; + const loc = u.file != null && u.line != null ? `${u.file}:${u.line}` : (u.file ?? ''); + const sev = u.severity != null ? String(u.severity) : ''; + const msg = (u.message ?? ''); + return `[UTP] ${utp.type} ${sev} ${loc} ${msg}`.replace(/\s+/gu, ' ').trim(); + } + case 'TestStatus': { + const u = utp; + const name = (u.name ?? u.description ?? '—').trim(); + const dur = u.duration ?? (u.durationMicroseconds != null ? u.durationMicroseconds / 1000 : 0); + const msg = (u.message ?? ''); + return `[UTP] TestStatus state=${u.state ?? '?'} durMs=${dur} ${name} ${msg}`.replace(/\s+/gu, ' ').trim(); + } + case 'TestPlan': + case 'ScreenSettings': + case 'PlayerSettings': + case 'BuildSettings': + case 'PlayerSystemInfo': + case 'QualitySettings': + return `[UTP] ${utp.type} ${JSON.stringify(utp)}`; + default: + return undefined; + } +} function sanitizeStackTrace(raw) { if (!raw) { return undefined; } const sanitized = raw - .replace(githubAnnotationPrefixRegex, '') + .replace(annotationCommandPrefixRegex, '') .replace(ansiEscapeSequenceRegex, '') .trim(); if (sanitized === '') { @@ -6298,6 +7411,126 @@ function sanitizeStackTrace(raw) { } return sanitized; } +const MAX_STACK_FRAME_ANNOTATIONS = 5; +const MAX_PLAIN_SCAN_ANNOTATIONS = 100; +function normalizePathSlashes(filePath) { + return path.normalize(filePath).replace(/\\/g, '/'); +} +/** + * Normalizes a candidate issue file path for annotation and project-path checks. + * - absoluteFile: used for `isFileUnderProjectPath` gating. + * - annotationFile: project-relative path preferred for GitHub annotation rendering. + */ +function normalizeAnnotationPath(filePath, projectPath) { + if (!filePath) { + return {}; + } + const trimmed = filePath.trim(); + if (!trimmed) { + return {}; + } + const projectRootAbsolute = projectPath ? path.resolve(projectPath) : undefined; + const normalizedProject = projectRootAbsolute ? normalizePathSlashes(projectRootAbsolute) : undefined; + const isAbsolute = path.isAbsolute(trimmed); + const absoluteFile = normalizePathSlashes(isAbsolute + ? trimmed + : (projectRootAbsolute ? path.resolve(projectRootAbsolute, trimmed) : trimmed)); + if (!normalizedProject) { + return { absoluteFile, annotationFile: normalizePathSlashes(trimmed) }; + } + if (!isFileUnderProjectPath(absoluteFile, normalizedProject)) { + return { absoluteFile }; + } + const relative = normalizePathSlashes(path.relative(normalizedProject, absoluteFile)); + if (!relative || relative.startsWith('../')) { + return { absoluteFile }; + } + return { + absoluteFile, + annotationFile: relative, + }; +} +function parsePlainLogIssue(line) { + const paren = line.match(/^(.+?)\((\d+)(?:,\d+)?\):\s*(warning|error)\b[:\s-]*(.*)$/i); + if (paren && paren[1] && paren[2] && paren[3]) { + const severity = paren[3].toLowerCase() === 'warning' ? utp_1.Severity.Warning : utp_1.Severity.Error; + const file = paren[1].trim().replace(/\\/g, '/'); + const lineNum = parseInt(paren[2], 10); + const remainder = (paren[4] ?? '').trim(); + const message = remainder.length > 0 ? remainder : line.trim(); + const issue = { severity, file, message }; + if (Number.isFinite(lineNum)) { + issue.line = lineNum; + } + return issue; + } + const colon = line.match(/^(.+?):(\d+):\s*(warning|error)\b[:\s-]*(.*)$/i); + if (colon && colon[1] && colon[2] && colon[3]) { + const severity = colon[3].toLowerCase() === 'warning' ? utp_1.Severity.Warning : utp_1.Severity.Error; + const file = colon[1].trim().replace(/\\/g, '/'); + const lineNum = parseInt(colon[2], 10); + const remainder = (colon[4] ?? '').trim(); + const message = remainder.length > 0 ? remainder : line.trim(); + const issue = { severity, file, message }; + if (Number.isFinite(lineNum)) { + issue.line = lineNum; + } + return issue; + } + const generic = line.match(/\b(error|warning)\b[:\s-]+(.+)/i); + if (generic && generic[1] && generic[2]) { + const severity = generic[1].toLowerCase() === 'warning' ? utp_1.Severity.Warning : utp_1.Severity.Error; + return { severity, message: generic[2].trim() }; + } + return undefined; +} +/** + * True if filePath is the project root or under it. Normalizes separators; on Windows compares case-insensitively. + * Exported for unit tests. + */ +function isFileUnderProjectPath(filePath, projectRoot) { + const normFile = normalizePathSlashes(filePath); + const normRoot = normalizePathSlashes(projectRoot); + const base = normRoot.endsWith('/') ? normRoot : `${normRoot}/`; + if (process.platform === 'win32') { + const f = normFile.toLowerCase(); + const r = normRoot.toLowerCase(); + const b = base.toLowerCase(); + return f === r || f.startsWith(b); + } + return normFile === normRoot || normFile.startsWith(base); +} +function parseStackFrames(stackTrace, projectPath) { + const frames = []; + const lines = stackTrace.split(/\r?\n/).map(l => l.trim()).filter(Boolean); + for (const stackLine of lines) { + const inMatch = stackLine.match(/\s+in\s+([^\s]+):(\d+)\s*$/); + const parenMatch = stackLine.match(/\(([^)]+):(\d+)\)\s*$/); + const plainMatch = stackLine.match(/^(.+):(\d+)\s*$/); + let file; + let lineNum; + if (inMatch && inMatch[1] != null && inMatch[2] != null) { + file = inMatch[1].replace(/\\/g, '/'); + lineNum = parseInt(inMatch[2], 10); + } + else if (parenMatch && parenMatch[1] != null && parenMatch[2] != null) { + file = parenMatch[1].replace(/\\/g, '/'); + lineNum = parseInt(parenMatch[2], 10); + } + else if (plainMatch && plainMatch[1] != null && plainMatch[2] != null) { + file = plainMatch[1].replace(/\\/g, '/'); + lineNum = parseInt(plainMatch[2], 10); + } + const line = lineNum !== undefined && Number.isFinite(lineNum) ? lineNum : undefined; + if (file != null && line != null && line > 0) { + const normalized = normalizeAnnotationPath(file, projectPath); + if (projectPath != null && normalized.absoluteFile && normalized.annotationFile) { + frames.push({ file: normalized.annotationFile, line, title: stackLine }); + } + } + } + return frames; +} const MIN_DESCRIPTION_COLUMN_WIDTH = 16; const DEFAULT_TERMINAL_WIDTH = 120; const TERMINAL_WIDTH_SAFETY_MARGIN = 2; @@ -6991,24 +8224,6 @@ async function writeUtpTelemetryLog(filePath, entries, logger) { logger.warn(`Failed to write UTP telemetry log (${filePath}): ${error}`); } } -/** - * Editor log messages whose severity has been changed. - * Useful for making certain error messages that are not critical less noisy. - * Key is a substring of the log message, value is the remapped LogLevel. - */ -const remappedEditorLogs = { - 'OpenCL device, baking cannot use GPU lightmapper.': logging_1.LogLevel.INFO, - 'Failed to find a suitable OpenCL device, baking cannot use GPU lightmapper.': logging_1.LogLevel.INFO, - '~StackAllocator(ALLOC_TEMP_MAIN) m_LastAlloc not NULL. Did you forget to call FreeAllStackAllocations()?': logging_1.LogLevel.INFO, -}; -function getRemappedEditorLogLevel(message) { - for (const [fragment, level] of Object.entries(remappedEditorLogs)) { - if (message.includes(fragment)) { - return level; - } - } - return undefined; -} /** * Tails a log file using fs.watch and ReadStream for efficient reading. * @param logPath The path to the log file to tail. @@ -7021,11 +8236,24 @@ function TailLogFile(logPath, projectPath) { const logPollingInterval = 250; let pendingPartialLine = ''; const telemetry = []; + const testResults = []; + const scannedLogEntries = []; + const seenIssueKeys = new Set(); + const seenAnnotationKeys = new Set(); + let plainScanAnnotations = 0; + /** Dedupe stdout test table rows when Unity emits duplicate TestStatus lines (key: name + state + description). */ + const seenTestStatusKeys = new Set(); const logger = logging_1.Logger.instance; const actionAccumulator = new ActionTelemetryAccumulator(); - const actionTableRenderer = new ActionTableRenderer(process.stdout.isTTY === true && process.env.CI !== 'true'); + const actionTableRenderer = new ActionTableRenderer((0, utilities_1.isStdoutTTY)()); const utpLogPath = buildUtpLogPath(logPath); let telemetryFlushed = false; + const buildIssueKey = (file, lineNo, message) => { + const normalized = normalizeAnnotationPath(file, projectPath); + const canonicalFile = (normalized.absoluteFile ?? normalizePathSlashes(file ?? '')).toLowerCase(); + const canonicalLine = lineNo ?? 0; + return `${canonicalFile}\u0000${canonicalLine}\u0000${message}`; + }; const renderActionTable = () => { const snapshot = actionAccumulator.snapshot(); if (snapshot) { @@ -7038,6 +8266,13 @@ function TailLogFile(logPath, projectPath) { } telemetryFlushed = true; await writeUtpTelemetryLog(utpLogPath, telemetry, logger); + const parsed = path.parse(logPath); + logging_1.Logger.instance.CI_appendWorkflowSummary(parsed.name, telemetry, projectPath != null && projectPath !== '' ? { projectPath, additionalLogEntries: scannedLogEntries } : { additionalLogEntries: scannedLogEntries }); + if (testResults.length > 0) { + const limit = logger.getMarkdownByteLimit('stdout'); + const summary = (0, logging_1.buildUnitTestJobSummaryMarkdown)(testResults, limit, '\n'); + process.stdout.write(summary); + } }; const writeStdoutThenTableContent = (content, restoreTable = true) => { actionTableRenderer.prepareForContent(); @@ -7060,39 +8295,66 @@ function TailLogFile(logPath, projectPath) { return; } const utpJson = JSON.parse(sanitizedJson); - const utp = (0, utp_1.normalizeTelemetryEntry)(utpJson); + const { utp, unknownTopLevelKeys } = (0, utp_1.normalizeTelemetryEntry)(utpJson); + if (unknownTopLevelKeys.length > 0) { + logger.warn(formatUtpUnrecognizedTopLevelPropertiesMessage(unknownTopLevelKeys, line)); + } telemetry.push(utp); - if (utp.message && 'severity' in utp && - (utp.severity === utp_1.Severity.Error || utp.severity === utp_1.Severity.Exception || utp.severity === utp_1.Severity.Assert)) { - let messageLevel = logging_1.LogLevel.ERROR; - const remappedLevel = getRemappedEditorLogLevel(utp.message); - if (remappedLevel !== undefined) { - messageLevel = remappedLevel; + const utpMsg = (utp.message ?? '').trim(); + if ((utp.type === 'LogEntry' || utp.type === 'Compiler') && utpMsg !== '') { + seenIssueKeys.add(buildIssueKey(utp.file, utp.line, utpMsg)); + } + if (utp.type === 'TestStatus') { + const ts = utp; + const dedupeKey = `${ts.name ?? ''}\u0000${ts.state ?? ''}\u0000${ts.description ?? ''}`; + if (!seenTestStatusKeys.has(dedupeKey)) { + seenTestStatusKeys.add(dedupeKey); + const result = (0, logging_1.utpToTestResultSummary)(utp); + testResults.push(result); + } + if ((ts.state === 2 || ts.state === 0) && ts.message && !annotationCommandPrefixRegex.test(ts.message)) { + const normalizedPath = normalizeAnnotationPath(utp.file, projectPath); + const lineNumber = utp.line; + const title = (ts.name ?? ts.description ?? 'Test failure').trim(); + if (normalizedPath.annotationFile && lineNumber) { + const key = buildIssueKey(normalizedPath.annotationFile, lineNumber, ts.message); + if (!seenAnnotationKeys.has(key)) { + seenAnnotationKeys.add(key); + logger.annotate(ts.state === 2 ? logging_1.LogLevel.ERROR : logging_1.LogLevel.WARN, ts.message, normalizedPath.annotationFile, lineNumber, undefined, undefined, undefined, title); + } + } } - const file = utp.file ? utp.file.replace(/\\/g, '/') : undefined; + } + if (utp.message && 'severity' in utp && (0, utp_1.isElevatedUtpSeverity)(utp.severity)) { + const normalizedPath = normalizeAnnotationPath(utp.file, projectPath); const stacktrace = sanitizeStackTrace(utp.stackTrace); const message = stacktrace == undefined ? utp.message : `${utp.message}\n${stacktrace}`; - if (!githubAnnotationPrefixRegex.test(message)) { + if (!annotationCommandPrefixRegex.test(message)) { // only annotate if the file is within the current project - if (projectPath && file && file.startsWith(projectPath)) { - logger.annotate(logging_1.LogLevel.ERROR, message, file, utp.line); + if (normalizedPath.annotationFile) { + logger.annotate(logging_1.LogLevel.ERROR, message, normalizedPath.annotationFile, utp.line); + // Link stack trace to annotations: emit one annotation per frame (capped) for clickable stack in Checks + if (stacktrace && projectPath) { + const frames = parseStackFrames(stacktrace, projectPath); + const toEmit = frames.slice(0, MAX_STACK_FRAME_ANNOTATIONS); + for (const frame of toEmit) { + logger.annotate(logging_1.LogLevel.ERROR, frame.title, frame.file, frame.line, undefined, undefined, undefined, 'Stack frame'); + } + } } else { - switch (messageLevel) { - case logging_1.LogLevel.WARN: - logger.warn(message); - break; - case logging_1.LogLevel.ERROR: - logger.error(message); - break; - case logging_1.LogLevel.INFO: - default: - logger.info(message); - break; - } + logger.error(message); } } } + else if (utp.message && (0, utp_benign_1.utpMessageMatchesBenignRemap)(utp.message)) { + // Remapped at normalize time (e.g. multicast WSAEACCES); surface as info, not error. + const stacktrace = sanitizeStackTrace(utp.stackTrace); + const message = stacktrace == undefined ? utp.message : `${utp.message}\n${stacktrace}`; + if (!annotationCommandPrefixRegex.test(message)) { + logger.info(message); + } + } else if (logging_1.Logger.instance.logLevel === logging_1.LogLevel.UTP) { printUTP(utp); } @@ -7102,6 +8364,44 @@ function TailLogFile(logPath, projectPath) { } } else { + // Skip plain-log false positives (e.g. "Socket: bind failed, error: …" matching \berror\b). + if ((0, utp_benign_1.utpMessageMatchesBenignRemap)(line)) { + if (logging_1.Logger.instance.logLevel !== logging_1.LogLevel.UTP) { + process.stdout.write(`${line}\n`); + } + return; + } + const scan = parsePlainLogIssue(line); + if (scan) { + if ((0, utp_benign_1.utpMessageMatchesBenignRemap)(scan.message)) { + if (logging_1.Logger.instance.logLevel !== logging_1.LogLevel.UTP) { + process.stdout.write(`${line}\n`); + } + return; + } + const key = buildIssueKey(scan.file, scan.line, scan.message); + if (!seenIssueKeys.has(key)) { + seenIssueKeys.add(key); + scannedLogEntries.push({ + type: 'Compiler', + severity: scan.severity, + message: scan.message, + file: scan.file, + line: scan.line, + }); + } + if (!annotationCommandPrefixRegex.test(scan.message) && plainScanAnnotations < MAX_PLAIN_SCAN_ANNOTATIONS) { + const normalizedPath = normalizeAnnotationPath(scan.file, projectPath); + const annotationKey = buildIssueKey(normalizedPath.annotationFile ?? scan.file, scan.line, scan.message); + if (!seenAnnotationKeys.has(annotationKey)) { + if (normalizedPath.annotationFile && scan.line) { + seenAnnotationKeys.add(annotationKey); + plainScanAnnotations++; + logger.annotate(scan.severity === utp_1.Severity.Warning ? logging_1.LogLevel.WARN : logging_1.LogLevel.ERROR, scan.message, normalizedPath.annotationFile, scan.line); + } + } + } + } if (logging_1.Logger.instance.logLevel !== logging_1.LogLevel.UTP) { process.stdout.write(`${line}\n`); } @@ -7119,6 +8419,7 @@ function TailLogFile(logPath, projectPath) { break; } case 'MemoryLeaks': + case 'MemoryLeak': logger.debug(formatMemoryLeakTable(utp)); break; case 'PlayerBuildInfo': { @@ -7129,11 +8430,16 @@ function TailLogFile(logPath, projectPath) { } break; } - default: + default: { + const desc = describeUtpForUtpLogLevel(utp); + if (desc !== undefined) { + logger.debug(desc); + break; + } logger.warn(`UTP entry has unknown type: ${utp.type ?? 'undefined'}`); - // Print raw JSON for unhandled UTP types writeStdoutThenTableContent(`${JSON.stringify(utp)}\n`); break; + } } } async function readNewLogContent() { @@ -7382,6 +8688,12 @@ class UnityVersion { semVer; logger = logging_1.Logger.instance; constructor(version, changeset = undefined, architecture = undefined) { + // Accept ProjectVersion / matrix style: "5.6.7f1 (e80cc3114ac1)" (no regex: avoid ReDoS). + const embedded = UnityVersion.tryParseEmbeddedChangeset(version); + if (embedded) { + version = embedded.version; + changeset = changeset ?? embedded.changeset; + } this.version = version; this.changeset = changeset; this.semVer = UnityVersion.createSemVer(version); @@ -7440,9 +8752,12 @@ class UnityVersion { this.logger.debug(`Found Unity ${latest.version}`); return new UnityVersion(latest.version, null, this.architecture); } + throw new Error(`No Unity release matching ${this.version} for channel(s) [${channels.join(', ')}]. ` + + (channels.length === 1 && channels[0] === 'f' + ? `No stable (f) release for ${this.version}; use --channel b/a or a fully-qualified version (e.g. 6000.6.0b7).` + : `Try a different --channel or a fully-qualified version.`)); } - this.logger.debug(`No matching Unity version found for ${this.version}`); - return this; + throw new Error(`No matching Unity version found for ${this.version}`); } satisfies(version) { return (0, semver_1.satisfies)(version.semVer, `^${this.semVer.version}`); @@ -7471,6 +8786,35 @@ class UnityVersion { } static UNITY_RELEASE_PATTERN = /^(\d{1,4})\.(\d+)\.(\d+)([abcfpx])(\d+)$/; static VERSION_TOKEN_PATTERN = /^(\d{1,4})(?:\.(\d+|x|\*))?(?:\.(\d+|x|\*))?/; + /** + * Parses trailing " (hexchangeset)" without regex to avoid ReDoS on hostile input. + */ + static tryParseEmbeddedChangeset(raw) { + if (!raw.endsWith(')')) { + return null; + } + const open = raw.lastIndexOf('('); + if (open <= 0 || raw[open - 1] !== ' ') { + return null; + } + const hex = raw.slice(open + 1, -1); + if (hex.length === 0) { + return null; + } + for (let i = 0; i < hex.length; i++) { + const c = hex.charCodeAt(i); + const isHex = (c >= 48 && c <= 57) || // 0-9 + (c >= 97 && c <= 102) || // a-f + (c >= 65 && c <= 70); // A-F + if (!isHex) { + return null; + } + } + return { + version: raw.slice(0, open - 1).trimEnd(), + changeset: hex, + }; + } static UNITY_CHANNEL_ORDER = { a: 0, b: 1, @@ -7616,6 +8960,482 @@ exports.UnityVersion = UnityVersion; /***/ }), +/***/ 7501: +/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { + +"use strict"; + +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.UpmCli = void 0; +const fs = __importStar(__nccwpck_require__(7147)); +const os = __importStar(__nccwpck_require__(2037)); +const path = __importStar(__nccwpck_require__(1017)); +const semver_1 = __nccwpck_require__(1383); +const logging_1 = __nccwpck_require__(4486); +const utilities_1 = __nccwpck_require__(9746); +/** + * Managed Unity Package Manager CLI (unity-cli–installed `UnityPackageManager`), modeled after {@link UnityHub}: + * parameterless constructor resolves roots and executable preference, {@link Install} manages downloads, {@link Exec} runs the binary. + */ +class UpmCli { + /** Root directory for managed installs (~/.unity-cli/upm), analogous to {@link UnityHub.rootDirectory}. */ + managedRoot; + logger = logging_1.Logger.instance; + constructor() { + this.managedRoot = path.join(os.homedir(), '.unity-cli', 'upm'); + } + static getCdnBaseUrl() { + const override = process.env.UPM_CDN_BASE_URL?.trim(); + if (override && override.length > 0) { + return `${override.replace(/\/$/, '')}/upm-cli`; + } + return 'https://cdn.packages.unity.com/upm-cli'; + } + /** + * HTTPS URL under the UPM CLI CDN for a release file. Caller must validate `tag` (e.g. {@link UpmCli.validateVersionFormat}); + * path segments are encoded to avoid tainted file-derived strings reaching the network unchecked (CodeQL js/file-access-to-http). + */ + static buildUpmReleaseAssetUrl(cdnBase, tag, fileName) { + const root = new URL(`${cdnBase.replace(/\/$/, '')}/`); + return new URL(`releases/${encodeURIComponent(tag)}/${encodeURIComponent(fileName)}`, root).href; + } + static buildUpmLatestTxtUrl(cdnBase) { + return new URL('latest.txt', new URL(`${cdnBase.replace(/\/$/, '')}/`)).href; + } + static normalizeSemver(version) { + const normalized = (0, semver_1.valid)(version); + if (normalized) { + return normalized; + } + const coerced = (0, semver_1.coerce)(version); + return coerced?.version; + } + static parseVerifiedSemVerFromLine(line) { + const t = line.trim(); + if (!t) { + return null; + } + const direct = (0, semver_1.valid)(t); + if (direct) { + const parsed = (0, semver_1.parse)(direct, false); + if (parsed && (0, semver_1.valid)(parsed.version)) { + return parsed; + } + } + const coerced = (0, semver_1.coerce)(t); + if (coerced && (0, semver_1.valid)(coerced)) { + return coerced; + } + return null; + } + static parseCliVersionStdout(output) { + const trimmed = output.trim(); + if (!trimmed) { + throw new Error('Upm cli --version produced empty output.'); + } + const lines = trimmed.split(/\r?\n/).map((l) => l.trim()).filter((l) => l.length > 0); + for (let i = lines.length - 1; i >= 0; i--) { + const version = UpmCli.parseVerifiedSemVerFromLine(lines[i]); + if (version) { + return version; + } + } + const fallback = UpmCli.parseVerifiedSemVerFromLine(trimmed); + if (fallback) { + return fallback; + } + throw new Error(`Failed to parse upm cli version: ${JSON.stringify(trimmed)}`); + } + getVersionInstallDir(version) { + const t = version.trim(); + this.validateVersionFormat(t); + if (t.includes('..') || path.normalize(t) !== t) { + throw new Error(`Invalid upm cli release tag for path use: ${version}`); + } + const dir = path.join(this.managedRoot, t); + const resolvedDir = path.resolve(dir); + const resolvedRoot = path.resolve(this.managedRoot); + const rootPrefix = resolvedRoot.endsWith(path.sep) ? resolvedRoot : `${resolvedRoot}${path.sep}`; + if (resolvedDir !== resolvedRoot && !resolvedDir.startsWith(rootPrefix)) { + throw new Error('Resolved UPM install directory left managed root.'); + } + return dir; + } + getCurrentVersionFilePath() { + return path.join(this.managedRoot, 'current-version.txt'); + } + getPlatformId() { + const plat = process.platform; + const arch = process.arch; + if (plat === 'win32') { + if (arch === 'arm64') { + return 'windows-arm64'; + } + return 'windows-x64'; + } + if (plat === 'darwin') { + if (arch === 'arm64') { + return 'macos-arm64'; + } + return 'macos-x64'; + } + if (plat === 'linux') { + if (arch === 'arm64') { + return 'linux-arm64'; + } + return 'linux-x64'; + } + throw new Error(`Unsupported platform for upm cli: ${plat} ${arch}`); + } + validateVersionFormat(version) { + const t = version.trim(); + if (!t.startsWith('v') || !(0, semver_1.valid)(t)) { + throw new Error(`Invalid upm cli version format: ${version}. Expected a semver release tag with leading v (e.g. v9.27.0).`); + } + } + findPrimaryExecutable(installDir) { + if (process.platform === 'win32') { + const exe = path.join(installDir, 'UnityPackageManager.exe'); + if (fs.existsSync(exe)) { + return exe; + } + } + else { + const bin = path.join(installDir, 'UnityPackageManager'); + if (fs.existsSync(bin)) { + return bin; + } + } + throw new Error(`Could not find UnityPackageManager binary under ${installDir}`); + } + /** Optional executable override (mirrors `UNITY_HUB_PATH` for {@link UnityHub}). */ + getExecutablePathOverride() { + const p = process.env.UPM_CLI_PATH?.trim(); + return p && p.length > 0 ? path.normalize(p) : undefined; + } + executableOverrideIsUsable() { + const p = this.getExecutablePathOverride(); + if (!p) { + return false; + } + try { + fs.accessSync(p, fs.constants.R_OK | fs.constants.X_OK); + return true; + } + catch { + return false; + } + } + /** + * Release tag of the managed install from `current-version.txt`, if present and valid. + */ + GetInstalledReleaseTag() { + const currentFile = this.getCurrentVersionFilePath(); + if (!fs.existsSync(currentFile)) { + return undefined; + } + try { + const version = fs.readFileSync(currentFile, 'utf8').trim(); + if (!version) { + return undefined; + } + this.validateVersionFormat(version); + return version; + } + catch { + return undefined; + } + } + /** + * Path to the primary `UnityPackageManager` binary for a managed release, or `undefined` if missing. + */ + ResolveManagedPrimaryPath(version) { + let v = version?.trim() || this.GetInstalledReleaseTag(); + if (!v) { + return undefined; + } + const installDir = this.getVersionInstallDir(v); + try { + return this.findPrimaryExecutable(installDir); + } + catch { + return undefined; + } + } + /** + * Resolved path used to spawn the UPM CLI: `UPM_CLI_PATH` override when set, otherwise the managed primary binary. + * @throws If nothing usable is installed (mirrors Hub/Editor behavior when the executable cannot be used). + */ + GetExecutablePath() { + const overridePath = this.getExecutablePathOverride(); + if (overridePath) { + fs.accessSync(overridePath, fs.constants.R_OK | fs.constants.X_OK); + return overridePath; + } + const managed = this.ResolveManagedPrimaryPath(); + if (!managed) { + throw new Error('Upm cli is not installed. Run `unity-cli upm-install` first.'); + } + fs.accessSync(managed, fs.constants.R_OK | fs.constants.X_OK); + return managed; + } + /** Same role as {@link UnityHub.executable}: path used to spawn the UPM CLI (may reflect `UPM_CLI_PATH` or the managed install). */ + get executable() { + return this.GetExecutablePath(); + } + async GetLatestReleaseTag() { + const cdn = UpmCli.getCdnBaseUrl(); + const latestUrl = UpmCli.buildUpmLatestTxtUrl(cdn); + const version = (await (0, utilities_1.HttpsGetText)(latestUrl)).trim(); + this.validateVersionFormat(version); + return version; + } + /** True if `latestTag` is newer than the installed managed release, or nothing is installed yet. */ + IsUpdateAvailable(latestTag) { + const current = this.GetInstalledReleaseTag(); + if (!current) { + return true; + } + const normalizedCurrent = UpmCli.normalizeSemver(current); + const normalizedLatest = UpmCli.normalizeSemver(latestTag); + if (normalizedCurrent && normalizedLatest) { + return (0, semver_1.compare)(normalizedLatest, normalizedCurrent) > 0; + } + return latestTag.trim() !== current.trim(); + } + /** + * Installs or updates the managed UPM CLI (mirrors {@link UnityHub.Install} for the Hub itself). + * @returns Installed release tag (e.g. v9.27.0). + */ + async Install(options) { + const cdn = UpmCli.getCdnBaseUrl(); + let version = options?.version?.trim(); + if (!version || version.length === 0) { + version = await this.GetLatestReleaseTag(); + } + version = version.trim(); + const installDir = this.getVersionInstallDir(version); + const markerPath = path.join(installDir, '.unity-cli-upm-installed'); + if (options?.skipIfInstalled !== false && fs.existsSync(markerPath)) { + try { + this.findPrimaryExecutable(installDir); + const recordedTag = path.basename(installDir); + await fs.promises.writeFile(this.getCurrentVersionFilePath(), `${recordedTag}\n`, 'utf8'); + return version; + } + catch { + // reinstall + } + } + const platform = this.getPlatformId(); + const zipName = `upm-${platform}.zip`; + const zipUrl = UpmCli.buildUpmReleaseAssetUrl(cdn, version, zipName); + const checksumUrl = UpmCli.buildUpmReleaseAssetUrl(cdn, version, `${zipName}.sha256`); + const tempRoot = path.join((0, utilities_1.GetTempDir)(), `unity-cli-upm-${Date.now()}`); + const resolvedTempRoot = path.resolve(tempRoot); + const zipPath = path.join(resolvedTempRoot, zipName); + const checksumPath = path.join(resolvedTempRoot, `${zipName}.sha256`); + try { + this.logger.info(`Installing upm cli ${version} (${platform})...`); + await (0, utilities_1.DownloadFile)(zipUrl, zipPath); + await (0, utilities_1.DownloadFile)(checksumUrl, checksumPath); + const checksumContent = (await fs.promises.readFile(checksumPath, 'utf8')).trim(); + const expectedHash = checksumContent.split(/\s+/)[0]?.toLowerCase(); + if (!expectedHash) { + throw new Error(`Could not read SHA-256 from ${checksumPath}`); + } + const actualHash = (await (0, utilities_1.Sha256FileHex)(zipPath)).toLowerCase(); + if (actualHash !== expectedHash) { + throw new Error(`SHA-256 mismatch for upm cli zip. Expected ${expectedHash}, got ${actualHash}`); + } + await (0, utilities_1.DeleteDirectory)(installDir); + await fs.promises.mkdir(installDir, { recursive: true }); + await (0, utilities_1.extractZipNative)(zipPath, installDir, { + zipUnder: resolvedTempRoot, + destUnder: path.resolve(this.managedRoot), + }, { + silent: false, + showCommand: this.logger.logLevel === logging_1.LogLevel.DEBUG + }); + const primary = this.findPrimaryExecutable(installDir); + if (process.platform !== 'win32') { + try { + fs.chmodSync(primary, 0o755); + } + catch { + // ignore + } + } + const wrapperUnix = path.join(installDir, 'upm'); + if (process.platform !== 'win32' && fs.existsSync(wrapperUnix)) { + try { + fs.chmodSync(wrapperUnix, 0o755); + } + catch { + // ignore + } + } + await fs.promises.writeFile(markerPath, `${new Date().toISOString()}\n`, 'utf8'); + const recordedTag = path.basename(installDir); + await fs.promises.writeFile(this.getCurrentVersionFilePath(), `${recordedTag}\n`, 'utf8'); + return version; + } + finally { + await (0, utilities_1.DeleteDirectory)(tempRoot); + } + } + /** + * When running in an interactive terminal, may prompt to install a missing UPM CLI or update to the latest CDN release. + * When not interactive, logs a warning if the running binary is older than the CDN latest (no install). + * Compares the running binary ({@link Version}) to {@link GetLatestReleaseTag} (including when {@code UPM_CLI_PATH} overrides the managed install). + * Call before {@link GetExecutablePath} / {@link Exec} for Hub-style optional install/update (e.g. pack). + */ + async PromptInstallOrUpdateWhenInteractive() { + const overrideUsable = this.executableOverrideIsUsable(); + const managedExe = this.ResolveManagedPrimaryPath(); + const hasExecutable = overrideUsable || managedExe !== undefined; + if (!hasExecutable) { + if ((0, utilities_1.isInteractiveTerminalSession)()) { + const install = await (0, utilities_1.PromptYesNo)('The upm cli is not installed. Download and install it now?', true); + if (install) { + await this.Install({ skipIfInstalled: false }); + } + } + return; + } + try { + const latestTag = await this.GetLatestReleaseTag(); + const latestSem = UpmCli.parseVerifiedSemVerFromLine(latestTag); + if (!latestSem) { + return; + } + const installedSem = await this.Version(); + if ((0, semver_1.compare)(latestSem, installedSem) <= 0) { + return; + } + const usingOverride = overrideUsable; + if (!(0, utilities_1.isInteractiveTerminalSession)()) { + if (usingOverride) { + this.logger.warn(`The upm cli (UPM_CLI_PATH) reports ${installedSem.version}, but ${latestTag} is available on the CDN. This run still uses UPM_CLI_PATH; update that binary or unset it and run unity-cli upm-install to use the managed release.`); + } + else { + this.logger.warn(`The upm cli (${installedSem.version}) is older than the latest release (${latestTag}). Run unity-cli upm-install or unity-cli upm-install --auto-update to update.`); + } + return; + } + const prompt = usingOverride + ? `Your upm cli (UPM_CLI_PATH) reports ${installedSem.version}, but ${latestTag} is available. Install the latest to the managed location now? This run will keep using UPM_CLI_PATH until you unset it or point it at the new binary.` + : `A newer upm cli version is available (${installedSem.version} -> ${latestTag}). Install it now?`; + const shouldUpdate = await (0, utilities_1.PromptYesNo)(prompt, !usingOverride); + if (!shouldUpdate) { + return; + } + await this.Install({ + version: latestTag, + skipIfInstalled: false, + }); + if (usingOverride) { + this.logger.warn(`Installed upm cli ${latestTag} under ${this.managedRoot}. Unset UPM_CLI_PATH (or update it) so subsequent commands use the new install.`); + } + } + catch (error) { + this.logger.debug(`Failed to check for upm cli updates: ${error}`); + } + } + /** + * Executes the UPM CLI with the given arguments (mirrors {@link UnityHub.Exec}). + */ + async Exec(args, options = { silent: this.logger.logLevel > logging_1.LogLevel.CI, showCommand: this.logger.logLevel <= logging_1.LogLevel.CI }) { + const exe = this.GetExecutablePath(); + if (exe.includes(path.sep)) { + fs.accessSync(exe, fs.constants.R_OK | fs.constants.X_OK); + } + return (0, utilities_1.Exec)(exe, args, options); + } + /** + * Runs `--version` and returns the verified semver from the binary. + * @param expectedReleaseTag When set (e.g. from {@link Install}), ensures the reported semver matches this CDN release tag. + */ + async Version(expectedReleaseTag) { + const raw = await this.Exec(['--version'], { + silent: true, + showCommand: this.logger.logLevel === logging_1.LogLevel.DEBUG, + }); + const version = UpmCli.parseCliVersionStdout(raw); + if (expectedReleaseTag !== undefined && expectedReleaseTag.trim().length > 0) { + const tag = expectedReleaseTag.trim(); + const expected = UpmCli.parseVerifiedSemVerFromLine(tag); + if (!expected) { + throw new Error(`Invalid installed upm cli release tag: ${expectedReleaseTag}`); + } + if ((0, semver_1.compare)(version, expected) !== 0) { + throw new Error(`Upm cli binary version mismatch: binary reported ${version.version} (--version), expected ${expected.version} (${expectedReleaseTag}).`); + } + } + return version; + } + /** + * Runs the UPM CLI `pack` subcommand (builds argv from {@link UpmPackOptions}, then {@link Exec}). + */ + async Pack(options, execOptions) { + const orgId = options.organizationId.trim(); + if (!orgId) { + throw new Error('UpmCli.Pack requires a non-empty organizationId.'); + } + const args = []; + if (this.logger.logLevel === logging_1.LogLevel.DEBUG) { + args.push('--log-level', '5', '--console-log-level', '5'); + } + args.push('pack', '--organization-id', orgId); + const dest = options.destination?.trim(); + if (dest && dest.length > 0) { + args.push('--destination', dest); + } + const dir = options.packageDirectory?.trim(); + if (dir && dir.length > 0) { + args.push(dir); + } + return this.Exec(args, execOptions); + } +} +exports.UpmCli = UpmCli; +//# sourceMappingURL=upm-cli.js.map + +/***/ }), + /***/ 9746: /***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { @@ -7658,7 +9478,15 @@ Object.defineProperty(exports, "__esModule", ({ value: true })); exports.ResolveGlobToPath = ResolveGlobToPath; exports.ResolvePathCandidates = ResolvePathCandidates; exports.PromptForSecretInput = PromptForSecretInput; +exports.PromptYesNo = PromptYesNo; +exports.isInteractiveTerminalSession = isInteractiveTerminalSession; +exports.isStdoutTTY = isStdoutTTY; +exports.orderedRedactionSecrets = orderedRedactionSecrets; +exports.redactSensitiveLiterals = redactSensitiveLiterals; exports.Exec = Exec; +exports.extractZipNative = extractZipNative; +exports.HttpsGetText = HttpsGetText; +exports.Sha256FileHex = Sha256FileHex; exports.DownloadFile = DownloadFile; exports.DeleteDirectory = DeleteDirectory; exports.ReadFileContents = ReadFileContents; @@ -7672,12 +9500,13 @@ exports.KillProcess = KillProcess; exports.KillChildProcesses = KillChildProcesses; exports.isProcessElevated = isProcessElevated; exports.tryParseJson = tryParseJson; +const crypto = __importStar(__nccwpck_require__(6113)); const os = __importStar(__nccwpck_require__(2037)); const fs = __importStar(__nccwpck_require__(7147)); const path = __importStar(__nccwpck_require__(1017)); const https = __importStar(__nccwpck_require__(5687)); const readline = __importStar(__nccwpck_require__(4521)); -const glob_1 = __nccwpck_require__(8211); +const glob_1 = __nccwpck_require__(5979); const child_process_1 = __nccwpck_require__(2081); const logging_1 = __nccwpck_require__(4486); const logger = logging_1.Logger.instance; @@ -7734,6 +9563,72 @@ async function PromptForSecretInput(prompt) { }); }); } +/** + * Prompts for y/n. Empty input uses `defaultYes` (Y/n vs y/N suffix). + */ +async function PromptYesNo(prompt, defaultYes) { + return new Promise((resolve) => { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + const hint = defaultYes ? ' [Y/n]: ' : ' [y/N]: '; + rl.question(`${prompt}${hint}`, (input) => { + rl.close(); + const a = input.trim().toLowerCase(); + if (a.length === 0) { + resolve(defaultYes); + return; + } + resolve(a === 'y' || a === 'yes'); + }); + }); +} +/** + * True when stdin and stdout are TTYs and the process is not running under CI. + * Use before interactive prompts (readline). + */ +function isInteractiveTerminalSession() { + return (process.stdin.isTTY === true && + process.stdout.isTTY === true && + process.env.CI !== 'true'); +} +/** + * True when {@link process.stdout} is a TTY and the process is not running under CI. + * Use for terminal-only output (e.g. live tables, ANSI) that does not read from stdin. + * This is not the same as {@link isInteractiveTerminalSession} (which also requires a TTY on stdin for prompts). + */ +function isStdoutTTY() { + return process.stdout.isTTY === true && process.env.CI !== 'true'; +} +/** Dedupes, trims, drops short values, longest-first (so one secret cannot leak via another). */ +function orderedRedactionSecrets(literals) { + if (!literals || literals.length === 0) { + return []; + } + const seen = new Set(); + for (const raw of literals) { + const s = raw.trim(); + if (s.length >= 4) { + seen.add(s); + } + } + return [...seen].sort((a, b) => b.length - a.length); +} +/** Replaces each configured literal with `*****` everywhere it appears in `text`. */ +function redactSensitiveLiterals(text, literals) { + const secrets = orderedRedactionSecrets(literals); + if (secrets.length === 0 || text.length === 0) { + return text; + } + let result = text; + for (const sec of secrets) { + if (result.includes(sec)) { + result = result.split(sec).join('*****'); + } + } + return result; +} /** * Executes a command with arguments and options. * @param command The command to execute. @@ -7748,8 +9643,10 @@ async function Exec(command, args, options = { silent: false, showCommand: true const isDebug = logger.logLevel === logging_1.LogLevel.DEBUG; const isSilent = isDebug ? false : options.silent ? options.silent : false; const mustShowCommand = isDebug ? true : options.showCommand ? options.showCommand : false; + const redactionSecrets = orderedRedactionSecrets(options.redactLiterals); + const redact = (text) => redactionSecrets.length === 0 ? text : redactSensitiveLiterals(text, redactionSecrets); if (mustShowCommand) { - const commandStr = `\x1b[34m${command} ${args.join(' ')}\x1b[0m`; + const commandStr = redact(`\x1b[34m${command} ${args.join(' ')}\x1b[0m`); if (isSilent) { logger.info(commandStr); } @@ -7762,9 +9659,13 @@ async function Exec(command, args, options = { silent: false, showCommand: true } try { exitCode = await new Promise((resolve, reject) => { + const spawnEnv = options.env !== undefined && Object.keys(options.env).length > 0 + ? { ...process.env, ...options.env } + : undefined; const child = (0, child_process_1.spawn)(command, args, { - env: process.env, + shell: false, stdio: ['ignore', 'pipe', 'pipe'], + ...(spawnEnv !== undefined ? { env: spawnEnv } : {}), }); const sigintHandler = () => child.kill('SIGINT'); const sigtermHandler = () => child.kill('SIGTERM'); @@ -7794,9 +9695,10 @@ async function Exec(command, args, options = { silent: false, showCommand: true lineBuffer = ''; } for (const line of lines) { - output += `${line}\n`; + const safeLine = redact(line); + output += `${safeLine}\n`; if (!isSilent) { - process.stdout.write(`${line}\n`); + process.stdout.write(`${safeLine}\n`); } } } @@ -7821,9 +9723,10 @@ async function Exec(command, args, options = { silent: false, showCommand: true .map(line => line.replace(/\r$/, '')) // remove trailing carriage return .filter(line => line.length > 0); // filter out empty lines for (const line of lines) { - output += `${line}\n`; + const safeLine = redact(line); + output += `${safeLine}\n`; if (!isSilent) { - process.stdout.write(`${line}\n`); + process.stdout.write(`${safeLine}\n`); } } } @@ -7844,13 +9747,101 @@ async function Exec(command, args, options = { silent: false, showCommand: true } } if (exitCode !== 0) { - throw new Error(`${command} failed with exit code ${exitCode}\n${output}`); + const tail = isSilent && output.length > 0 ? `\n${output}` : ''; + throw new Error(`${command} failed with exit code ${exitCode}${tail}`); } } return output; } +function assertResolvedPathUnderRoot(candidate, root, label) { + const resolved = path.resolve(candidate); + const resolvedRoot = path.resolve(root); + const prefix = resolvedRoot.endsWith(path.sep) ? resolvedRoot : `${resolvedRoot}${path.sep}`; + if (resolved !== resolvedRoot && !resolved.startsWith(prefix)) { + throw new Error(`${label}: path is outside permitted root (${root}): ${candidate}`); + } +} +/** + * Extracts a zip archive using OS tools (PowerShell on Windows, `unzip` elsewhere). + */ +async function extractZipNative(zipPath, destDir, pathTrust, execOptions) { + assertResolvedPathUnderRoot(zipPath, pathTrust.zipUnder, 'extractZipNative zipPath'); + assertResolvedPathUnderRoot(destDir, pathTrust.destUnder, 'extractZipNative destDir'); + await fs.promises.mkdir(destDir, { recursive: true }); + const silent = execOptions?.silent ?? true; + const show = execOptions?.showCommand ?? false; + if (process.platform === 'win32') { + const scriptBody = 'param([Parameter(Mandatory=$true)][string]$ZipPath,[Parameter(Mandatory=$true)][string]$DestPath)\n' + + '$ErrorActionPreference = "Stop"\n' + + 'Expand-Archive -LiteralPath $ZipPath -DestinationPath $DestPath -Force\n'; + const tmpDir = await fs.promises.mkdtemp(path.join(GetTempDir(), 'unity-cli-expand-zip-')); + const scriptPath = path.join(tmpDir, 'Expand-Archive.ps1'); + try { + await fs.promises.writeFile(scriptPath, scriptBody, 'utf8'); + await Exec('powershell.exe', [ + '-NoProfile', + '-NonInteractive', + '-File', + scriptPath, + zipPath, + destDir, + ], { + silent, + showCommand: show, + }); + } + finally { + await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => undefined); + } + } + else { + await Exec('unzip', [ + '-o', + '-q', + zipPath, + '-d', + destDir + ], { + silent, + showCommand: show + }); + } +} +/** + * GET an HTTPS URL and return the response body as UTF-8 text (trimmed). + * @throws If the response status is not 200 or the request fails. + */ +async function HttpsGetText(url) { + return new Promise((resolve, reject) => { + https.get(url, (response) => { + if (response.statusCode !== 200) { + reject(new Error(`GET ${url} failed: HTTP ${response.statusCode}`)); + response.resume(); + return; + } + const chunks = []; + response.on('data', (c) => chunks.push(c)); + response.on('end', () => resolve(Buffer.concat(chunks).toString('utf8').trim())); + }).on('error', reject); + }); +} +/** + * Computes the SHA-256 digest of a file as a lowercase hex string. + */ +async function Sha256FileHex(filePath) { + const hash = crypto.createHash('sha256'); + const stream = fs.createReadStream(filePath); + return new Promise((resolve, reject) => { + stream.on('data', (chunk) => { + hash.update(chunk); + }); + stream.on('end', () => resolve(hash.digest('hex'))); + stream.on('error', reject); + }); +} /** * Downloads a file from a URL to a specified path. + * Requires HTTP status 200 before writing. Verifies the file is readable after download. * @param url The URL to download from. * @param downloadPath The path to save the downloaded file. * @throws An error if the download fails or the file is not accessible after download. @@ -7859,20 +9850,31 @@ async function DownloadFile(url, downloadPath) { logger.ci(`Downloading from ${url} to ${downloadPath}...`); await fs.promises.mkdir(path.dirname(downloadPath), { recursive: true }); await new Promise((resolve, reject) => { - const file = fs.createWriteStream(downloadPath, { mode: 0o755 }); https.get(url, (response) => { + if (response.statusCode !== 200) { + response.resume(); + reject(new Error(`GET ${url} failed: HTTP ${response.statusCode}`)); + return; + } + const file = fs.createWriteStream(downloadPath, { mode: 0o755 }); + const fail = (err) => { + file.destroy(); + void fs.promises.unlink(downloadPath).catch(() => undefined); + reject(err); + }; + response.once('error', fail); + file.once('error', fail); response.pipe(file); file.on('finish', () => { - file.close(); - resolve(); + file.close(() => resolve()); }); }).on('error', (error) => { - fs.unlink(downloadPath, () => reject(`Download failed: ${error}`)); + void fs.promises.unlink(downloadPath).catch(() => undefined); + reject(error); }); }); - // make sure the file is closed and accessible await new Promise((r) => setTimeout(r, 100)); - await fs.promises.access(downloadPath, fs.constants.R_OK | fs.constants.X_OK); + await fs.promises.access(downloadPath, fs.constants.R_OK); } /** * Deletes a directory and its contents if it exists. @@ -8155,15 +10157,69 @@ function tryParseJson(content) { /***/ }), -/***/ 881: +/***/ 6239: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.UTP_BENIGN_SEVERITY_REMAPS = void 0; +exports.utpMessageMatchesBenignRemap = utpMessageMatchesBenignRemap; +/** + * Known Unity/editor messages that are non-actionable despite elevated UTP severity. + * Kept in a leaf module (no imports) so normalize, summaries, and CI share one list + * without circular deps between utp.ts and logging.ts. + * + * Severity strings must match {@link Severity} in utp.ts. + */ +exports.UTP_BENIGN_SEVERITY_REMAPS = [ + // Longer OpenCL form first so summary strip does not leave a "Failed to find a suitable" prefix. + { fragment: 'Failed to find a suitable OpenCL device, baking cannot use GPU lightmapper.', severity: 'Info' }, + { fragment: 'OpenCL device, baking cannot use GPU lightmapper.', severity: 'Info' }, + { + fragment: '~StackAllocator(ALLOC_TEMP_MAIN) m_LastAlloc not NULL. Did you forget to call FreeAllStackAllocations()?', + severity: 'Info', + }, + // Windows hosted CI: WSAEACCES (10013) — player-connection multicast / socket bind. Unity falls back. + { fragment: 'Unable to join player connection multicast group', severity: 'Info' }, + { fragment: 'Socket: bind failed', severity: 'Info' }, + { + fragment: 'An attempt was made to access a socket in a way forbidden by its access permissions', + severity: 'Info', + }, + { fragment: 'Access token is unavailable; failed to update', severity: 'Info' }, +]; +/** True if the message matches a known benign Unity/CI noise fragment. */ +function utpMessageMatchesBenignRemap(message) { + if (!message) { + return false; + } + for (const { fragment } of exports.UTP_BENIGN_SEVERITY_REMAPS) { + if (message.includes(fragment)) { + return true; + } + } + return false; +} +//# sourceMappingURL=utp-benign.js.map + +/***/ }), + +/***/ 6282: /***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { "use strict"; Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.Severity = exports.Phase = exports.UTPPlayerBuildInfo = exports.UTPTestStatus = exports.UTPQualitySettings = exports.UTPPlayerSystemInfo = exports.UTPBuildSettings = exports.UTPPlayerSettings = exports.UTPScreenSettings = exports.UTPTestPlan = exports.UTPLogEntry = exports.UTPMemoryLeak = exports.UTPBase = void 0; +exports.UTP_SUPPORTED_TOP_LEVEL_PROPERTIES = exports.Severity = exports.Phase = exports.UTPPlayerBuildInfo = exports.UTPTestStatus = exports.UTPQualitySettings = exports.UTPPlayerSystemInfo = exports.UTPBuildSettings = exports.UTPPlayerSettings = exports.UTPScreenSettings = exports.UTPTestPlan = exports.UTPCompiler = exports.UTPLogEntry = exports.UTPMemoryLeaks = exports.UTPMemoryLeak = exports.UTPAction = exports.UTPBase = exports.utpMessageMatchesBenignRemap = exports.UTP_BENIGN_SEVERITY_REMAPS = void 0; +exports.isElevatedUtpSeverity = isElevatedUtpSeverity; +exports.remapBenignUtpSeverity = remapBenignUtpSeverity; exports.normalizeTelemetryEntry = normalizeTelemetryEntry; const logging_1 = __nccwpck_require__(4486); +const utp_benign_1 = __nccwpck_require__(6239); +var utp_benign_2 = __nccwpck_require__(6239); +Object.defineProperty(exports, "UTP_BENIGN_SEVERITY_REMAPS", ({ enumerable: true, get: function () { return utp_benign_2.UTP_BENIGN_SEVERITY_REMAPS; } })); +Object.defineProperty(exports, "utpMessageMatchesBenignRemap", ({ enumerable: true, get: function () { return utp_benign_2.utpMessageMatchesBenignRemap; } })); class UTPBase { type; version; @@ -8184,14 +10240,23 @@ class UTPBase { errors; } exports.UTPBase = UTPBase; +class UTPAction extends UTPBase { +} +exports.UTPAction = UTPAction; class UTPMemoryLeak extends UTPBase { allocatedMemory; memoryLabels; } exports.UTPMemoryLeak = UTPMemoryLeak; +class UTPMemoryLeaks extends UTPMemoryLeak { +} +exports.UTPMemoryLeaks = UTPMemoryLeaks; class UTPLogEntry extends UTPBase { } exports.UTPLogEntry = UTPLogEntry; +class UTPCompiler extends UTPBase { +} +exports.UTPCompiler = UTPCompiler; class UTPTestPlan extends UTPBase { tests; } @@ -8222,6 +10287,7 @@ class UTPTestStatus extends UTPBase { } exports.UTPTestStatus = UTPTestStatus; class UTPPlayerBuildInfo extends UTPBase { + success; steps; } exports.UTPPlayerBuildInfo = UTPPlayerBuildInfo; @@ -8239,7 +10305,33 @@ var Severity; Severity["Exception"] = "Exception"; Severity["Assert"] = "Assert"; })(Severity || (exports.Severity = Severity = {})); -const allowedUtpKeys = new Set([ +/** Severities that normally fail builds / CI expected-success checks. */ +function isElevatedUtpSeverity(severity) { + return severity === Severity.Error + || severity === Severity.Exception + || severity === Severity.Assert; +} +/** + * Downgrades elevated severity on known benign messages. Mutates `utp`. + * @returns true when severity was changed. + */ +function remapBenignUtpSeverity(utp) { + if (!utp.message || !isElevatedUtpSeverity(utp.severity)) { + return false; + } + for (const { fragment, severity } of utp_benign_1.UTP_BENIGN_SEVERITY_REMAPS) { + if (utp.message.includes(fragment)) { + utp.severity = severity; + return true; + } + } + return false; +} +/** + * Root-level JSON keys on UTP objects that this CLI recognizes. Other keys are still parsed + * but reported via {@link normalizeTelemetryEntry}'s `unknownTopLevelKeys` for logging. + */ +exports.UTP_SUPPORTED_TOP_LEVEL_PROPERTIES = new Set([ 'allocatedMemory', 'BuildSettings', 'description', @@ -8261,6 +10353,7 @@ const allowedUtpKeys = new Set([ 'QualitySettings', 'ScreenSettings', 'severity', + 'success', 'stacktrace', 'stackTrace', 'state', @@ -8271,11 +10364,13 @@ const allowedUtpKeys = new Set([ 'version', ]); /** - * Normalizes UTP telemetry entries to canonical shapes and reports unexpected properties. + * Normalizes UTP telemetry entries to canonical shapes and remaps known benign elevated + * severities. Unknown top-level keys are listed for the caller to log (with the raw + * `##utp:` line when tailing logs). */ function normalizeTelemetryEntry(entry) { if (!entry || typeof entry !== 'object') { - return entry; + return { utp: entry, unknownTopLevelKeys: [] }; } const utp = entry; const record = entry; @@ -8297,19 +10392,24 @@ function normalizeTelemetryEntry(entry) { if (utp.lineNumber === undefined && typeof utp.line === 'number') { utp.lineNumber = utp.line; } + // Canonicalize severity string casing from Unity payloads. + if (typeof utp.severity === 'string') { + const matched = Object.values(Severity).find(s => s.toLowerCase() === utp.severity.toLowerCase()); + if (matched) { + utp.severity = matched; + } + } + remapBenignUtpSeverity(utp); if (!utp.type) { logging_1.Logger.instance.warn('UTP entry missing type property; telemetry entry may be ignored.'); } - const extras = []; + const unknownTopLevelKeys = []; for (const key of Object.keys(record)) { - if (!allowedUtpKeys.has(key)) { - extras.push(key); + if (!exports.UTP_SUPPORTED_TOP_LEVEL_PROPERTIES.has(key)) { + unknownTopLevelKeys.push(key); } } - if (extras.length > 0) { - logging_1.Logger.instance.warn(`UTP entry contains unrecognized properties: ${extras.join(', ')}`); - } - return utp; + return { utp, unknownTopLevelKeys }; } //# sourceMappingURL=utp.js.map @@ -10444,6 +12544,9 @@ class Range { } parseRange (range) { + // strip build metadata so it can't bleed into the version + range = range.replace(BUILDSTRIPRE, '') + // memoize range parsing for performance. // this is a very hot path, and fully deterministic. const memoOpts = @@ -10569,6 +12672,7 @@ const debug = __nccwpck_require__(427) const SemVer = __nccwpck_require__(8088) const { safeRe: re, + src, t, comparatorTrimReplace, tildeTrimReplace, @@ -10576,6 +12680,9 @@ const { } = __nccwpck_require__(9523) const { FLAG_INCLUDE_PRERELEASE, FLAG_LOOSE } = __nccwpck_require__(2293) +// unbounded global build-metadata stripper used by parseRange +const BUILDSTRIPRE = new RegExp(src[t.BUILD], 'g') + const isNullSet = c => c.value === '<0.0.0-0' const isAny = c => c.value === '' @@ -10616,6 +12723,11 @@ const parseComparator = (comp, options) => { const isX = id => !id || id.toLowerCase() === 'x' || id === '*' +const invalidXRangeOrder = (M, m, p) => ( + (isX(M) && !isX(m)) || + (isX(m) && p && !isX(p)) +) + // ~, ~> --> * (any, kinda silly) // ~2, ~2.x, ~2.x.x, ~>2, ~>2.x ~>2.x.x --> >=2.0.0 <3.0.0-0 // ~2.0, ~2.0.x, ~>2.0, ~>2.0.x --> >=2.0.0 <2.1.0-0 @@ -10633,6 +12745,10 @@ const replaceTildes = (comp, options) => { const replaceTilde = (comp, options) => { const r = options.loose ? re[t.TILDELOOSE] : re[t.TILDE] + // if we're including prereleases in the match, then the lower bound is + // -0, the lowest possible prerelease value, just like x-ranges and carets. + // this keeps `~1.2` equivalent to the `1.2.x` x-range it's documented as. + const z = options.includePrerelease ? '-0' : '' return comp.replace(r, (_, M, m, p, pr) => { debug('tilde', comp, _, M, m, p, pr) let ret @@ -10640,10 +12756,10 @@ const replaceTilde = (comp, options) => { if (isX(M)) { ret = '' } else if (isX(m)) { - ret = `>=${M}.0.0 <${+M + 1}.0.0-0` + ret = `>=${M}.0.0${z} <${+M + 1}.0.0-0` } else if (isX(p)) { // ~1.2 == >=1.2.0 <1.3.0-0 - ret = `>=${M}.${m}.0 <${M}.${+m + 1}.0-0` + ret = `>=${M}.${m}.0${z} <${M}.${+m + 1}.0-0` } else if (pr) { debug('replaceTilde pr', pr) ret = `>=${M}.${m}.${p}-${pr @@ -10712,10 +12828,10 @@ const replaceCaret = (comp, options) => { if (M === '0') { if (m === '0') { ret = `>=${M}.${m}.${p - }${z} <${M}.${m}.${+p + 1}-0` + } <${M}.${m}.${+p + 1}-0` } else { ret = `>=${M}.${m}.${p - }${z} <${M}.${+m + 1}.0-0` + } <${M}.${+m + 1}.0-0` } } else { ret = `>=${M}.${m}.${p @@ -10741,6 +12857,10 @@ const replaceXRange = (comp, options) => { const r = options.loose ? re[t.XRANGELOOSE] : re[t.XRANGE] return comp.replace(r, (ret, gtlt, M, m, p, pr) => { debug('xRange', comp, ret, gtlt, M, m, p, pr) + if (invalidXRangeOrder(M, m, p)) { + return comp + } + const xM = isX(M) const xm = xM || isX(m) const xp = xm || isX(p) @@ -10917,6 +13037,22 @@ const { safeRe: re, t } = __nccwpck_require__(9523) const parseOptions = __nccwpck_require__(785) const { compareIdentifiers } = __nccwpck_require__(5865) + +const isPrereleaseIdentifier = (prerelease, identifier) => { + const identifiers = identifier.split('.') + if (identifiers.length > prerelease.length) { + return false + } + + for (let i = 0; i < identifiers.length; i++) { + if (compareIdentifiers(prerelease[i], identifiers[i]) !== 0) { + return false + } + } + + return true +} + class SemVer { constructor (version, options) { options = parseOptions(options) @@ -11220,8 +13356,9 @@ class SemVer { if (identifierBase === false) { prerelease = [identifier] } - if (compareIdentifiers(this.prerelease[0], identifier) === 0) { - if (isNaN(this.prerelease[1])) { + if (isPrereleaseIdentifier(this.prerelease, identifier)) { + const prereleaseBase = this.prerelease[identifier.split('.').length] + if (isNaN(prereleaseBase)) { this.prerelease = prerelease } } else { @@ -11411,7 +13548,7 @@ module.exports = compareBuild /***/ }), -/***/ 3398: +/***/ 2804: /***/ ((module, __unused_webpack_exports, __nccwpck_require__) => { "use strict"; @@ -11848,7 +13985,7 @@ const patch = __nccwpck_require__(2866) const prerelease = __nccwpck_require__(4016) const compare = __nccwpck_require__(4309) const rcompare = __nccwpck_require__(6417) -const compareLoose = __nccwpck_require__(3398) +const compareLoose = __nccwpck_require__(2804) const compareBuild = __nccwpck_require__(2156) const sort = __nccwpck_require__(1426) const rsort = __nccwpck_require__(8701) @@ -12846,7 +14983,7 @@ const simpleSubset = (sub, dom, options) => { if (higher === c && higher !== gt) { return false } - } else if (gt.operator === '>=' && !satisfies(gt.semver, String(c), options)) { + } else if (gt.operator === '>=' && !c.test(gt.semver)) { return false } } @@ -12864,7 +15001,7 @@ const simpleSubset = (sub, dom, options) => { if (lower === c && lower !== lt) { return false } - } else if (lt.operator === '<=' && !satisfies(lt.semver, String(c), options)) { + } else if (lt.operator === '<=' && !c.test(lt.semver)) { return false } } @@ -16305,7 +18442,13 @@ function processHeader (request, key, val) { } else if (typeof val[i] === 'object') { throw new InvalidArgumentError(`invalid ${key} header`) } else { - arr.push(`${val[i]}`) + // Coerce primitives (and reject unsafe coercions such as functions + // with a crafted toString/Symbol.toPrimitive). + const str = `${val[i]}` + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`) + } + arr.push(str) } } val = arr @@ -16316,7 +18459,12 @@ function processHeader (request, key, val) { } else if (val === null) { val = '' } else { + // Coerce primitives (and reject unsafe coercions such as functions + // with a crafted toString/Symbol.toPrimitive). val = `${val}` + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`) + } } if (headerName === 'host') { @@ -17353,7 +19501,6 @@ function defaultFactory (origin, opts) { class Agent extends DispatcherBase { constructor ({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { - if (typeof factory !== 'function') { throw new InvalidArgumentError('factory must be a function.') } @@ -17694,6 +19841,7 @@ const { RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -17741,6 +19889,9 @@ const EMPTY_BUF = Buffer.alloc(0) const FastBuffer = Buffer[Symbol.species] const addListener = util.addListener const removeAllListeners = util.removeAllListeners +const kIdleSocketValidation = Symbol('kIdleSocketValidation') +const kIdleSocketValidationTimeout = Symbol('kIdleSocketValidationTimeout') +const kSocketUsed = Symbol('kSocketUsed') let extractBody @@ -17963,29 +20114,71 @@ class Parser { const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr - if (ret === constants.ERROR.PAUSED_UPGRADE) { - this.onUpgrade(data.slice(offset)) - } else if (ret === constants.ERROR.PAUSED) { - this.paused = true - socket.unshift(data.slice(offset)) - } else if (ret !== constants.ERROR.OK) { - const ptr = llhttp.llhttp_get_error_reason(this.ptr) - let message = '' - /* istanbul ignore else: difficult to make a test case for */ - if (ptr) { - const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0) - message = - 'Response does not match the HTTP/1.1 protocol (' + - Buffer.from(llhttp.memory.buffer, ptr, len).toString() + - ')' - } - throw new HTTPParserError(message, constants.ERROR[ret], data.slice(offset)) + if (ret !== constants.ERROR.OK) { + const body = data.subarray(offset) + + if (ret === constants.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(body) + } else if (ret === constants.ERROR.PAUSED) { + this.paused = true + socket.unshift(body) + } else { + throw this.createError(ret, body) + } } } catch (err) { util.destroy(socket, err) } } + finish () { + assert(currentParser === null) + assert(this.ptr != null) + assert(!this.paused) + + const { llhttp } = this + + let ret + + try { + currentParser = this + ret = llhttp.llhttp_finish(this.ptr) + } finally { + currentParser = null + } + + if (ret === constants.ERROR.OK) { + return null + } + + if (ret === constants.ERROR.PAUSED || ret === constants.ERROR.PAUSED_UPGRADE) { + this.paused = true + return null + } + + return this.createError(ret, EMPTY_BUF) + } + + createError (ret, data) { + const { llhttp, contentLength, bytesRead } = this + + if (contentLength && bytesRead !== parseInt(contentLength, 10)) { + return new ResponseContentLengthMismatchError() + } + + const ptr = llhttp.llhttp_get_error_reason(this.ptr) + let message = '' + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0) + message = + 'Response does not match the HTTP/1.1 protocol (' + + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + + ')' + } + + return new HTTPParserError(message, constants.ERROR[ret], data) + } + destroy () { assert(this.ptr != null) assert(currentParser == null) @@ -18013,6 +20206,11 @@ class Parser { return -1 } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError('bad response', util.getSocketInfo(socket))) + return -1 + } + const request = client[kQueue][client[kRunningIdx]] if (!request) { return -1 @@ -18116,6 +20314,11 @@ class Parser { return -1 } + if (client[kRunning] === 0) { + util.destroy(socket, new SocketError('bad response', util.getSocketInfo(socket))) + return -1 + } + const request = client[kQueue][client[kRunningIdx]] /* istanbul ignore next: difficult to make a test case for */ @@ -18289,6 +20492,7 @@ class Parser { request.onComplete(headers) client[kQueue][client[kRunningIdx]++] = null + socket[kSocketUsed] = true if (socket[kWriting]) { assert(client[kRunning] === 0) @@ -18347,6 +20551,9 @@ async function connectH1 (client, socket) { socket[kWriting] = false socket[kReset] = false socket[kBlocking] = false + socket[kIdleSocketValidation] = 0 + socket[kIdleSocketValidationTimeout] = null + socket[kSocketUsed] = false socket[kParser] = new Parser(client, socket, llhttpInstance) addListener(socket, 'error', function (err) { @@ -18357,8 +20564,11 @@ async function connectH1 (client, socket) { // On Mac OS, we get an ECONNRESET even if there is a full body to be forwarded // to the user. if (err.code === 'ECONNRESET' && parser.statusCode && !parser.shouldKeepAlive) { - // We treat all incoming data so for as a valid response. - parser.onMessageComplete() + const parserErr = parser.finish() + if (parserErr) { + this[kError] = parserErr + this[kClient][kOnError](parserErr) + } return } @@ -18377,8 +20587,10 @@ async function connectH1 (client, socket) { const parser = this[kParser] if (parser.statusCode && !parser.shouldKeepAlive) { - // We treat all incoming data so far as a valid response. - parser.onMessageComplete() + const parserErr = parser.finish() + if (parserErr) { + util.destroy(this, parserErr) + } return } @@ -18388,10 +20600,11 @@ async function connectH1 (client, socket) { const client = this[kClient] const parser = this[kParser] + clearIdleSocketValidation(this) + if (parser) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { - // We treat all incoming data so far as a valid response. - parser.onMessageComplete() + this[kError] = parser.finish() || this[kError] } this[kParser].destroy() @@ -18454,7 +20667,7 @@ async function connectH1 (client, socket) { return socket.destroyed }, busy (request) { - if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) { return true } @@ -18492,6 +20705,31 @@ async function connectH1 (client, socket) { } } +function clearIdleSocketValidation (socket) { + if (socket[kIdleSocketValidationTimeout]) { + clearTimeout(socket[kIdleSocketValidationTimeout]) + socket[kIdleSocketValidationTimeout] = null + } + + socket[kIdleSocketValidation] = 0 +} + +function scheduleIdleSocketValidation (client, socket) { + socket[kIdleSocketValidation] = 1 + socket[kIdleSocketValidationTimeout] = setTimeout(() => { + socket[kIdleSocketValidationTimeout] = null + socket[kIdleSocketValidation] = 2 + + if (client[kSocket] === socket && !socket.destroyed) { + client[kResume]() + } + }, 0) + socket[kIdleSocketValidationTimeout].unref?.() +} + +/** + * @param {import('./client.js')} client + */ function resumeH1 (client) { const socket = client[kSocket] @@ -18506,6 +20744,32 @@ function resumeH1 (client) { socket[kNoRef] = false } + if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) { + if (socket[kIdleSocketValidation] === 0) { + scheduleIdleSocketValidation(client, socket) + socket[kParser].readMore() + if (socket.destroyed) { + return + } + return + } + + if (socket[kIdleSocketValidation] === 1) { + socket[kParser].readMore() + if (socket.destroyed) { + return + } + return + } + } + + if (client[kRunning] === 0) { + socket[kParser].readMore() + if (socket.destroyed) { + return + } + } + if (client[kSize] === 0) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE) @@ -18561,8 +20825,16 @@ function writeH1 (client, request) { } body = bodyStream.stream contentLength = bodyStream.length - } else if (util.isBlobLike(body) && request.contentType == null && body.type) { - headers.push('content-type', body.type) + } else if (util.isBlobLike(body) && request.contentType == null) { + const contentType = body.type + if (contentType) { + const contentTypeValue = `${contentType}` + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request, new InvalidArgumentError('invalid content-type header')) + return false + } + headers.push('content-type', contentTypeValue) + } } if (body && typeof body.read === 'function') { @@ -18599,6 +20871,7 @@ function writeH1 (client, request) { } const socket = client[kSocket] + clearIdleSocketValidation(socket) const abort = (err) => { if (request.aborted || request.completed) { @@ -20471,6 +22744,7 @@ class DispatcherBase extends Dispatcher { get webSocketOptions () { return { + maxFragments: this[kWebSocketOptions].maxFragments ?? 131072, maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 } } @@ -22047,6 +24321,28 @@ function calculateRetryAfterHeader (retryAfter) { return new Date(retryAfter).getTime() - current } +function validatePartialResponseContentLength (headers, range, statusCode, retryCount) { + const contentLength = headers['content-length'] + if (contentLength == null) { + return null + } + + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null + } + + const length = Number(contentLength) + const expectedLength = range.end - range.start + 1 + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError('Content-Length mismatch', statusCode, { + headers, + data: { count: retryCount } + }) + } + + return null +} + class RetryHandler { constructor (opts, handlers) { const { retryOptions, ...dispatchOpts } = opts @@ -22261,6 +24557,12 @@ class RetryHandler { return false } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount) + if (contentLengthError != null) { + this.abort(contentLengthError) + return false + } + const { start, size, end = size - 1 } = contentRange assert(this.start === start, 'content-range mismatch') @@ -22284,6 +24586,12 @@ class RetryHandler { ) } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount) + if (contentLengthError != null) { + this.abort(contentLengthError) + return false + } + const { start, size, end = size - 1 } = range assert( start != null && Number.isFinite(start), @@ -26407,32 +28715,25 @@ function parseUnparsedAttributes (unparsedAttributes, cookieAttributeList = {}) // If the attribute-name case-insensitively matches the string // "SameSite", the user agent MUST process the cookie-av as follows: - // 1. Let enforcement be "Default". - let enforcement = 'Default' - const attributeValueLowercase = attributeValue.toLowerCase() - // 2. If cookie-av's attribute-value is a case-insensitive match for - // "None", set enforcement to "None". - if (attributeValueLowercase.includes('none')) { - enforcement = 'None' - } - - // 3. If cookie-av's attribute-value is a case-insensitive match for - // "Strict", set enforcement to "Strict". - if (attributeValueLowercase.includes('strict')) { - enforcement = 'Strict' - } - // 4. If cookie-av's attribute-value is a case-insensitive match for - // "Lax", set enforcement to "Lax". - if (attributeValueLowercase.includes('lax')) { - enforcement = 'Lax' + // 1. If cookie-av's attribute-value is a case-insensitive match for + // "None", append an attribute to the cookie-attribute-list with an + // attribute-name of "SameSite" and an attribute-value of "None". + if (attributeValueLowercase === 'none') { + cookieAttributeList.sameSite = 'None' + } else if (attributeValueLowercase === 'strict') { + // 2. If cookie-av's attribute-value is a case-insensitive match for + // "Strict", append an attribute to the cookie-attribute-list with + // an attribute-name of "SameSite" and an attribute-value of + // "Strict". + cookieAttributeList.sameSite = 'Strict' + } else if (attributeValueLowercase === 'lax') { + // 3. If cookie-av's attribute-value is a case-insensitive match for + // "Lax", append an attribute to the cookie-attribute-list with an + // attribute-name of "SameSite" and an attribute-value of "Lax". + cookieAttributeList.sameSite = 'Lax' } - - // 5. Append an attribute to the cookie-attribute-list with an - // attribute-name of "SameSite" and an attribute-value of - // enforcement. - cookieAttributeList.sameSite = enforcement } else { cookieAttributeList.unparsed ??= [] @@ -26562,7 +28863,7 @@ function validateCookiePath (path) { if ( code < 0x20 || // exclude CTLs (0-31) - code === 0x7F || // DEL + code > 0x7E || // exclude DEL and non-ascii code === 0x3B // ; ) { throw new Error('Invalid cookie path') @@ -26571,16 +28872,80 @@ function validateCookiePath (path) { } /** - * I have no idea why these values aren't allowed to be honest, - * but Deno tests these. - Khafra + * ::= | + * + * ::= any one of the 52 alphabetic characters A through Z in + * upper case and a through z in lower case + * + * ::= any one of the ten digits 0 through 9r + * + * @see https://www.rfc-editor.org/rfc/rfc1034#section-3.5 + * @param {number} code + */ +function isLetterOrDigit (code) { + return ( + (code >= 0x30 && code <= 0x39) || // 0-9 + (code >= 0x41 && code <= 0x5A) || // A-Z + (code >= 0x61 && code <= 0x7A) // a-z + ) +} + +/** + * Validates a cookie domain against the "preferred name syntax". + * + * ::= | " " + * ::=