@rage-against-the-pixel/unity-cli 3.0.2 → 4.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +86 -224
  2. package/dist/android-sdk.js +8 -1
  3. package/dist/android-sdk.js.map +1 -1
  4. package/dist/cli.js +180 -505
  5. package/dist/cli.js.map +1 -1
  6. package/dist/failure-markers.d.ts +17 -0
  7. package/dist/failure-markers.js +46 -0
  8. package/dist/failure-markers.js.map +1 -0
  9. package/dist/github-actions-ci.d.ts +1 -0
  10. package/dist/github-actions-ci.js +17 -3
  11. package/dist/github-actions-ci.js.map +1 -1
  12. package/dist/index.d.ts +2 -2
  13. package/dist/index.js +2 -2
  14. package/dist/index.js.map +1 -1
  15. package/dist/license-client.d.ts +119 -2
  16. package/dist/license-client.js +737 -56
  17. package/dist/license-client.js.map +1 -1
  18. package/dist/logger-provider.d.ts +2 -0
  19. package/dist/logger-provider.js +3 -0
  20. package/dist/logger-provider.js.map +1 -1
  21. package/dist/logging.d.ts +5 -0
  22. package/dist/logging.js +47 -0
  23. package/dist/logging.js.map +1 -1
  24. package/dist/process-tracker.d.ts +71 -0
  25. package/dist/process-tracker.js +213 -0
  26. package/dist/process-tracker.js.map +1 -0
  27. package/dist/unity-cli.d.ts +97 -0
  28. package/dist/unity-cli.js +591 -0
  29. package/dist/unity-cli.js.map +1 -0
  30. package/dist/unity-editor.d.ts +25 -16
  31. package/dist/unity-editor.js +204 -107
  32. package/dist/unity-editor.js.map +1 -1
  33. package/dist/unity-installer.d.ts +139 -0
  34. package/dist/unity-installer.js +1018 -0
  35. package/dist/unity-installer.js.map +1 -0
  36. package/dist/unity-logging.d.ts +16 -1
  37. package/dist/unity-logging.js +55 -3
  38. package/dist/unity-logging.js.map +1 -1
  39. package/dist/unity-project.d.ts +15 -0
  40. package/dist/unity-project.js +80 -0
  41. package/dist/unity-project.js.map +1 -1
  42. package/dist/upm-cli.d.ts +8 -8
  43. package/dist/upm-cli.js +8 -8
  44. package/dist/upm-cli.js.map +1 -1
  45. package/dist/utilities.d.ts +27 -7
  46. package/dist/utilities.js +119 -63
  47. package/dist/utilities.js.map +1 -1
  48. package/package.json +10 -11
  49. package/dist/unity-hub.d.ts +0 -140
  50. package/dist/unity-hub.js +0 -1425
  51. package/dist/unity-hub.js.map +0 -1
@@ -34,23 +34,56 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.LicensingClient = exports.LicenseType = void 0;
37
+ exports.LicensingClientDiagnostic = LicensingClientDiagnostic;
38
+ exports.GetLegacyLicenseVersion = GetLegacyLicenseVersion;
39
+ exports.ActivateLicense = ActivateLicense;
40
+ exports.IsSharedRunnerIdentity = IsSharedRunnerIdentity;
41
+ exports.ReturnLicense = ReturnLicense;
42
+ exports.PrintLicenseContext = PrintLicenseContext;
43
+ exports.GetLicenseFilePath = GetLicenseFilePath;
37
44
  const os = __importStar(require("os"));
38
45
  const fs = __importStar(require("fs"));
39
46
  const path = __importStar(require("path"));
40
47
  const child_process_1 = require("child_process");
41
48
  const logging_1 = require("./logging");
42
- const unity_hub_1 = require("./unity-hub");
49
+ const unity_cli_1 = require("./unity-cli");
43
50
  const utilities_1 = require("./utilities");
51
+ const process_tracker_1 = require("./process-tracker");
44
52
  var LicenseType;
45
53
  (function (LicenseType) {
46
54
  LicenseType["personal"] = "personal";
47
55
  LicenseType["professional"] = "professional";
48
56
  LicenseType["floating"] = "floating";
49
57
  })(LicenseType || (exports.LicenseType = LicenseType = {}));
58
+ /**
59
+ * The v3 licensing client, which drives Unity.Licensing.Client directly.
60
+ * v4 uses it as the fallback when the official Unity CLI cannot activate or return a license,
61
+ * for example a personal license on a headless runner, and for Unity 4.x and 5.x editors.
62
+ */
63
+ /** How long one Unity Licensing Client command may run. */
64
+ const LICENSING_CLIENT_TIMEOUT_MS = 10 * 60 * 1000;
65
+ // literal alternations only, so matching stays linear on any line
66
+ const LICENSING_DIAGNOSTIC_PATTERN = /error|fail|seat|entitlement|activation|limit|denied|invalid|expired/i;
67
+ /**
68
+ * The lines of sanitized licensing client output that say why a command failed, such as a seat limit.
69
+ * @param lines The client output, already sanitized.
70
+ * @returns At most the last 3 matching lines, each cut to 300 characters, or undefined when none match.
71
+ */
72
+ function LicensingClientDiagnostic(lines) {
73
+ const matches = lines
74
+ .map(line => line.trim())
75
+ .filter(line => line.length > 0 && LICENSING_DIAGNOSTIC_PATTERN.test(line))
76
+ .slice(-3)
77
+ .map(line => line.length > 300 ? `${line.slice(0, 300)}...` : line);
78
+ return matches.length > 0 ? matches.join('\n') : undefined;
79
+ }
50
80
  class LicensingClient {
51
- unityHub = new unity_hub_1.UnityHub();
52
81
  logger = logging_1.Logger.instance;
53
82
  licenseClientPath;
83
+ secrets = [];
84
+ isLicenseClientPatched = false;
85
+ /** Where the 4.x/5.x license link lives, and the folders the client may have written Unity_lic.ulf to. */
86
+ legacyLicense;
54
87
  licenseVersion;
55
88
  /**
56
89
  * Creates an instance of LicensingClient.
@@ -59,20 +92,186 @@ class LicensingClient {
59
92
  constructor(licenseVersion = undefined) {
60
93
  this.licenseVersion = licenseVersion;
61
94
  }
95
+ /**
96
+ * Finds Unity.Licensing.Client: UNITY_LICENSING_CLIENT_PATH, then the copy the official Unity CLI manages,
97
+ * then the Hub's external modules folder, then the client bundled with the editor in UNITY_EDITOR_PATH.
98
+ * @returns The licensing client executable.
99
+ */
62
100
  async init() {
63
- try {
64
- await fs.promises.access(this.unityHub.executable, fs.constants.R_OK);
65
- await fs.promises.access(this.unityHub.rootDirectory, fs.constants.R_OK);
101
+ const licensingClientExecutable = process.platform === 'win32' ? 'Unity.Licensing.Client.exe' : 'Unity.Licensing.Client';
102
+ let licenseClientPath = process.env.UNITY_LICENSING_CLIENT_PATH;
103
+ const legacyVersion = this.licenseVersion ?? GetLegacyLicenseVersion();
104
+ if (!licenseClientPath && process.platform === 'darwin' && (legacyVersion === '4.x' || legacyVersion === '5.x')) {
105
+ // the macOS client the Unity CLI downloads cannot be patched for 4.x/5.x, so use the one Unity Hub bundled
106
+ licenseClientPath = await LicensingClient.getHubBundledClient();
66
107
  }
67
- catch (error) {
68
- throw new Error('Unity Hub is not installed or not accessible. Please install Unity Hub before using the Licensing Client.');
108
+ if (!licenseClientPath) {
109
+ try {
110
+ const cli = await unity_cli_1.UnityCli.Get();
111
+ licenseClientPath = await cli.GetLicensingClientPath();
112
+ }
113
+ catch (error) {
114
+ this.logger.debug(`The Unity CLI did not report a licensing client: ${error}`);
115
+ }
69
116
  }
70
- const licensingClientExecutable = process.platform === 'win32' ? 'Unity.Licensing.Client.exe' : 'Unity.Licensing.Client';
71
- const licenseClientPath = await (0, utilities_1.ResolveGlobToPath)([this.unityHub.rootDirectory, '**', licensingClientExecutable]);
117
+ if (!licenseClientPath) {
118
+ const candidates = LicensingClient.externalModulesDirectories()
119
+ .map(directory => [directory, '**', licensingClientExecutable]);
120
+ const editorPath = process.env.UNITY_EDITOR_PATH;
121
+ if (editorPath && editorPath.length > 0) {
122
+ candidates.push([path.dirname(editorPath), '**', 'Licensing', 'Client', licensingClientExecutable]);
123
+ }
124
+ licenseClientPath = await (0, utilities_1.ResolvePathCandidates)(candidates);
125
+ }
126
+ if (!licenseClientPath) {
127
+ throw new Error('Failed to find Unity.Licensing.Client. Set UNITY_LICENSING_CLIENT_PATH to its location.');
128
+ }
129
+ await LicensingClient.ensureMacLicenseDirectory();
72
130
  this.licenseClientPath = licenseClientPath;
73
131
  await fs.promises.access(this.licenseClientPath, fs.constants.X_OK);
132
+ this.logger.debug(`Using Unity Licensing Client at ${this.licenseClientPath}`);
74
133
  return this.licenseClientPath;
75
134
  }
135
+ /**
136
+ * Extracts the licensing client bundled with Unity Hub 3.12.0 on macOS. Its resolver is a separate assembly that
137
+ * the 4.x/5.x patch works on, unlike the single-file macOS client the Unity CLI downloads, whose licenses stay 6.x.
138
+ * Only the client is copied out; the Hub is not installed.
139
+ * @returns The extracted client executable.
140
+ */
141
+ static async getHubBundledClient() {
142
+ const hubVersion = '3.12.0';
143
+ const arch = process.arch === 'arm64' ? 'arm64' : 'x64';
144
+ const cacheDirectory = await LicensingClient.privateDirectory(`hub-client-${hubVersion}-${arch}`);
145
+ const findClient = async () => (await fs.promises.readdir(cacheDirectory, { recursive: true }))
146
+ .map(entry => path.join(cacheDirectory, entry.toString()))
147
+ .find(entry => path.basename(entry) === 'Unity.Licensing.Client' && fs.statSync(entry).isFile());
148
+ if (fs.existsSync(cacheDirectory)) {
149
+ const cached = await findClient();
150
+ if (cached) {
151
+ return cached;
152
+ }
153
+ }
154
+ const dmgPath = path.join((0, utilities_1.GetTempDir)(), `UnityHubSetup-${hubVersion}-${arch}.dmg`);
155
+ const mountPoint = path.join((0, utilities_1.GetTempDir)(), `UnityHub-${hubVersion}-${arch}`);
156
+ // pinned so a tampered download never reaches the client that receives the Unity ID
157
+ const dmgSha256 = {
158
+ 'x64': '1b5e9698b6353cb814618ea12c6ca014d50fc33efe086da6b67e5b442a77bb3e',
159
+ 'arm64': '0026d98a375d926090b144ca3d54e982612aa24a59404e004e333dc34c4b832a',
160
+ };
161
+ await (0, utilities_1.DownloadFile)(`https://public-cdn.cloud.unity3d.com/hub/${hubVersion}/UnityHubSetup-${arch}.dmg`, dmgPath, { sha256: dmgSha256[arch] });
162
+ await fs.promises.mkdir(mountPoint, { recursive: true });
163
+ try {
164
+ await (0, utilities_1.Exec)('hdiutil', ['attach', dmgPath, '-nobrowse', '-readonly', '-mountpoint', mountPoint], { silent: true, showCommand: true });
165
+ try {
166
+ const client = await (0, utilities_1.ResolveGlobToPath)([mountPoint, '**', 'Unity.Licensing.Client']);
167
+ const sourceDirectory = LicensingClient.clientRootDirectory(client);
168
+ await fs.promises.rm(cacheDirectory, { recursive: true, force: true });
169
+ await fs.promises.cp(sourceDirectory, cacheDirectory, { recursive: true, verbatimSymlinks: true });
170
+ }
171
+ finally {
172
+ await (0, utilities_1.Exec)('hdiutil', ['detach', mountPoint, '-quiet'], { silent: true, showCommand: true });
173
+ }
174
+ }
175
+ finally {
176
+ await fs.promises.rm(dmgPath, { force: true });
177
+ }
178
+ const extracted = await findClient();
179
+ if (!extracted) {
180
+ throw new Error(`Failed to extract Unity.Licensing.Client from Unity Hub ${hubVersion}`);
181
+ }
182
+ logging_1.Logger.instance.info(`Using the Unity Licensing Client bundled with Unity Hub ${hubVersion}: ${extracted}`);
183
+ return extracted;
184
+ }
185
+ /**
186
+ * On macOS the client writes Unity_lic.ulf to /Library/Application Support/Unity. When that folder is missing or not
187
+ * writable it asks for an administrator password in a dialog, which never returns on a headless runner.
188
+ * The Unity Hub installer used to create it; without the Hub, it is created here with non-interactive sudo.
189
+ */
190
+ static async ensureMacLicenseDirectory() {
191
+ if (process.platform !== 'darwin') {
192
+ return;
193
+ }
194
+ const directory = '/Library/Application Support/Unity';
195
+ try {
196
+ await fs.promises.access(directory, fs.constants.W_OK);
197
+ return;
198
+ }
199
+ catch {
200
+ // missing or not writable
201
+ }
202
+ // owned by the current user rather than world-writable
203
+ const user = os.userInfo().username;
204
+ for (const args of [['-n', 'mkdir', '-p', directory], ['-n', 'chown', user, directory], ['-n', 'chmod', '755', directory]]) {
205
+ const exitCode = await new Promise((resolve) => {
206
+ const child = (0, child_process_1.spawn)('sudo', args, { stdio: 'ignore' });
207
+ child.on('error', () => resolve(1));
208
+ child.on('close', (code) => resolve(code ?? 1));
209
+ });
210
+ if (exitCode !== 0) {
211
+ logging_1.Logger.instance.warn(`Could not prepare ${directory} (sudo ${args.slice(1).join(' ')} exited with code ${exitCode}). Activation may wait for an administrator password.`);
212
+ return;
213
+ }
214
+ }
215
+ }
216
+ /**
217
+ * Gets a folder under ~/.unity-cli/licensing that only the current user can read and write, creating it when needed.
218
+ * Used instead of the shared temp folder for the patched licensing client and the license it writes.
219
+ * @param name The folder name.
220
+ */
221
+ static async privateDirectory(name) {
222
+ const root = path.join(os.homedir(), '.unity-cli', 'licensing');
223
+ await fs.promises.mkdir(root, { recursive: true, mode: 0o700 });
224
+ if (process.platform !== 'win32') {
225
+ await fs.promises.chmod(root, 0o700);
226
+ }
227
+ return path.join(root, name);
228
+ }
229
+ /**
230
+ * The folder that holds the whole licensing client: the bundle root for a macOS bundle (…/Contents/MacOS/client), otherwise the executable's folder.
231
+ */
232
+ static clientRootDirectory(clientPath) {
233
+ const macOSDirectory = path.dirname(clientPath);
234
+ if (path.basename(macOSDirectory) === 'MacOS' && path.basename(path.dirname(macOSDirectory)) === 'Contents') {
235
+ return path.dirname(path.dirname(macOSDirectory));
236
+ }
237
+ return macOSDirectory;
238
+ }
239
+ /**
240
+ * Lists the files under a directory that can hold compiled code: executables, libraries and extensionless binaries.
241
+ */
242
+ static async listBinaries(directory) {
243
+ const results = [];
244
+ for (const entry of await fs.promises.readdir(directory, { withFileTypes: true, recursive: true })) {
245
+ if (!entry.isFile()) {
246
+ continue;
247
+ }
248
+ const extension = path.extname(entry.name).toLowerCase();
249
+ // the macOS client is named Unity.Licensing.Client, so its extension reads as ".client"
250
+ const isClient = entry.name.startsWith('Unity.Licensing.') && !['.md', '.config', '.json', '.plist', '.xml', '.txt'].includes(extension);
251
+ if (isClient || ['', '.exe', '.dll', '.dylib', '.so'].includes(extension)) {
252
+ results.push(path.join(entry.parentPath, entry.name));
253
+ }
254
+ }
255
+ return results;
256
+ }
257
+ /**
258
+ * The folders the Unity Hub and the official Unity CLI download Unity.Licensing.Client to.
259
+ */
260
+ static externalModulesDirectories() {
261
+ switch (process.platform) {
262
+ case 'win32':
263
+ return [path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'UnityHub', 'external-modules')];
264
+ case 'darwin':
265
+ return [path.join(os.homedir(), 'Library', 'Application Support', 'UnityHub', 'external-modules')];
266
+ default: {
267
+ const configHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
268
+ return [
269
+ path.join(configHome, 'UnityHub', 'external-modules'),
270
+ path.join(configHome, 'unityhub', 'external-modules'),
271
+ ];
272
+ }
273
+ }
274
+ }
76
275
  getUnityCommonDir() {
77
276
  const result = process.env['UNITY_COMMON_DIR'];
78
277
  if (result) {
@@ -155,6 +354,11 @@ class LicensingClient {
155
354
  }
156
355
  throw new Error('Services config value is not a valid JSON string or base64 encoded JSON string.');
157
356
  }
357
+ /**
358
+ * Writes the floating license server's services-config.json where Unity's licensing reads it.
359
+ * @param configSource A path to the configuration file, raw JSON, or base64 encoded JSON.
360
+ * @returns The path the configuration was written to.
361
+ */
158
362
  async setupServicesConfig(configSource) {
159
363
  const servicesConfigPath = this.servicesConfigPath();
160
364
  if (fs.existsSync(configSource)) {
@@ -281,27 +485,8 @@ class LicensingClient {
281
485
  }
282
486
  async patchLicenseVersion() {
283
487
  if (!this.licenseVersion) {
284
- // check if the UNITY_EDITOR_PATH is set. If it is, use it to determine the license version
285
- const versionMatch = process.env.UNITY_EDITOR_PATH?.match(/(\d+)\.(\d+)\.(\d+)/);
286
- if (versionMatch) {
287
- switch (versionMatch[1]) {
288
- case '4': {
289
- this.licenseVersion = '4.x';
290
- break;
291
- }
292
- case '5': {
293
- this.licenseVersion = '5.x';
294
- break;
295
- }
296
- default: {
297
- this.licenseVersion = '6.x'; // default to 6.x for any other
298
- break;
299
- }
300
- }
301
- }
302
- else {
303
- this.licenseVersion = '6.x'; // default to 6.x if not set
304
- }
488
+ // use the editor in UNITY_EDITOR_PATH to determine the license version, defaulting to 6.x
489
+ this.licenseVersion = GetLegacyLicenseVersion() ?? '6.x';
305
490
  }
306
491
  if (this.licenseVersion !== '6.x') {
307
492
  if (this.licenseVersion !== '5.x' && this.licenseVersion !== '4.x') {
@@ -312,32 +497,78 @@ class LicensingClient {
312
497
  if (!this.licenseClientPath) {
313
498
  this.licenseClientPath = await this.init();
314
499
  }
315
- if (this.licenseVersion === '6.x') {
316
- return; // no patching needed
500
+ if (this.licenseVersion === '6.x' || this.isLicenseClientPatched) {
501
+ return; // no patching needed, or already done for this instance
317
502
  }
318
- const clientDirectory = path.dirname(this.licenseClientPath);
319
- const patchedDirectory = path.join(os.tmpdir(), `UnityLicensingClient-${this.licenseVersion.replace('.', '_')}`);
320
- if (await fs.promises.mkdir(patchedDirectory, { recursive: true }) === undefined) {
321
- this.logger.debug('Unity Licensing Client was already patched, reusing');
503
+ const clientRoot = LicensingClient.clientRootDirectory(this.licenseClientPath);
504
+ // a private per-user folder rather than the shared temp folder: it holds a client that receives the Unity ID and the license it writes
505
+ const patchedDirectory = await LicensingClient.privateDirectory(`patched-client-${this.licenseVersion.replace('.', '_')}`);
506
+ const patchedClientPath = path.join(patchedDirectory, path.relative(clientRoot, this.licenseClientPath));
507
+ const sourceMarker = path.join(patchedDirectory, '.source-sha256');
508
+ const sourceHash = await (0, utilities_1.Sha256FileHex)(this.licenseClientPath);
509
+ const isCurrent = (await fs.promises.readFile(sourceMarker, 'utf-8').catch(() => '')).trim() === sourceHash;
510
+ if (isCurrent) {
511
+ this.logger.debug('Unity Licensing Client was already patched from the same client, reusing');
322
512
  }
323
513
  else {
324
- let found = false;
325
- for (const fileName of await fs.promises.readdir(clientDirectory)) {
326
- if (fileName === 'Unity.Licensing.EntitlementResolver.dll') {
327
- await this.patchBinary(path.join(clientDirectory, fileName), path.join(patchedDirectory, fileName), Buffer.from('6.x', 'utf16le'), Buffer.from(this.licenseVersion, 'utf16le'));
328
- found = true;
514
+ await fs.promises.rm(patchedDirectory, { recursive: true, force: true });
515
+ await fs.promises.mkdir(patchedDirectory, { recursive: true, mode: 0o700 });
516
+ // copy the whole client, then patch whichever binary holds the "6.x" license version:
517
+ // older clients ship it in Unity.Licensing.EntitlementResolver.dll, the single-file client the Unity CLI downloads embeds it in the executable
518
+ await fs.promises.cp(clientRoot, patchedDirectory, { recursive: true, verbatimSymlinks: true });
519
+ const patchedFiles = [];
520
+ // Windows and Linux builds hold the value as a UTF-16 string; the macOS build only as the
521
+ // UTF-8 constant k_UnityLicenseVersionCompatibility, so that one is matched with its name
522
+ const constantName = Buffer.concat([Buffer.from('k_UnityLicenseVersionCompatibility'), Buffer.from([0x06])]);
523
+ const patterns = [
524
+ [Buffer.from('6.x', 'utf16le'), Buffer.from(this.licenseVersion, 'utf16le')],
525
+ [Buffer.concat([constantName, Buffer.from('6.x')]), Buffer.concat([constantName, Buffer.from(this.licenseVersion)])],
526
+ ];
527
+ const binaries = await LicensingClient.listBinaries(patchedDirectory);
528
+ // the UTF-8 constant is only patched when no UTF-16 value exists, which leaves the Windows and Linux patch as it was
529
+ for (const [searchValue, replaceValue] of patterns) {
530
+ for (const file of binaries) {
531
+ try {
532
+ await this.patchBinary(file, file, searchValue, replaceValue);
533
+ patchedFiles.push(file);
534
+ }
535
+ catch (error) {
536
+ if (!(error instanceof Error && error.message === 'Could not find the search value')) {
537
+ throw error;
538
+ }
539
+ }
329
540
  }
330
- else {
331
- await fs.promises.symlink(path.join(clientDirectory, fileName), path.join(patchedDirectory, fileName));
541
+ if (patchedFiles.length > 0) {
542
+ break;
332
543
  }
333
544
  }
334
- if (!found) {
335
- throw new Error('Could not find Unity.Licensing.EntitlementResolver.dll in the unityhub installation');
545
+ if (patchedFiles.length === 0) {
546
+ const searched = (await LicensingClient.listBinaries(patchedDirectory)).map(file => path.relative(patchedDirectory, file));
547
+ await fs.promises.rm(patchedDirectory, { recursive: true, force: true });
548
+ throw new Error(`Could not find the 6.x license version in any Unity Licensing Client binary under ${clientRoot}. Searched:\n${searched.map(file => ` > ${file}`).join('\n')}`);
549
+ }
550
+ this.logger.debug(`Patched the license version in:\n${patchedFiles.map(file => ` > ${file}`).join('\n')}`);
551
+ await fs.promises.chmod(patchedClientPath, 0o755);
552
+ if (process.platform === 'darwin') {
553
+ // patching invalidates the signature, and macOS kills binaries with a broken one
554
+ const isBundle = fs.existsSync(path.join(patchedDirectory, 'Contents', 'MacOS'));
555
+ const targets = isBundle ? [patchedDirectory] : patchedFiles;
556
+ for (const target of targets) {
557
+ await new Promise((resolve, reject) => {
558
+ const child = (0, child_process_1.spawn)('codesign', ['--force', '--deep', '--sign', '-', target], { stdio: 'ignore' });
559
+ child.on('error', reject);
560
+ child.on('close', (code) => code === 0 ? resolve() : reject(new Error(`codesign failed with exit code ${code}`)));
561
+ });
562
+ }
336
563
  }
564
+ // records which client the copy came from, so a newer client is patched again instead of reused
565
+ await fs.promises.writeFile(sourceMarker, sourceHash, { encoding: 'utf-8', mode: 0o600 });
337
566
  }
338
- this.licenseClientPath = path.join(patchedDirectory, path.basename(this.licenseClientPath));
567
+ this.licenseClientPath = patchedClientPath;
568
+ this.isLicenseClientPatched = true;
339
569
  const unityCommonDir = this.getUnityCommonDir();
340
570
  const legacyLicenseFile = path.join(unityCommonDir, `Unity_v${this.licenseVersion}.ulf`);
571
+ this.legacyLicense ??= { linkPath: legacyLicenseFile, commonDirectory: unityCommonDir, patchedDirectory };
341
572
  await fs.promises.mkdir(unityCommonDir, { recursive: true });
342
573
  try {
343
574
  await fs.promises.symlink(path.join(patchedDirectory, 'Unity_lic.ulf'), legacyLicenseFile);
@@ -360,24 +591,42 @@ class LicensingClient {
360
591
  }
361
592
  let output = '';
362
593
  let exitCode = 0;
363
- this.logger.startGroup(`\x1b[34m${this.licenseClientPath} ${args.join(' ')}\x1b[0m`);
594
+ let timedOut = false;
595
+ let killedBy = null;
596
+ // values that must never reach a log, even outside CI where ::add-mask:: does nothing
597
+ const sensitiveFlags = ['--username', '--password', '--serial', '--return-floating'];
598
+ const argSecrets = args.filter((_, i) => i > 0 && sensitiveFlags.includes(args[i - 1]));
599
+ const secrets = [...this.secrets, ...argSecrets];
600
+ const redact = (text) => (0, utilities_1.redactSensitiveLiterals)(text, secrets);
601
+ this.logger.startGroup(`\x1b[34m${this.licenseClientPath} ${redact(args.join(' '))}\x1b[0m`);
602
+ const logOffset = await this.getClientLogSize();
364
603
  await fs.promises.access(this.licenseClientPath, fs.constants.R_OK | fs.constants.X_OK);
365
604
  try {
366
605
  exitCode = await new Promise((resolve, reject) => {
367
606
  fs.accessSync(this.licenseClientPath, fs.constants.R_OK | fs.constants.X_OK);
607
+ const startedAt = Date.now();
368
608
  const child = (0, child_process_1.spawn)(this.licenseClientPath, args, {
369
- stdio: ['ignore', 'pipe', 'pipe']
609
+ stdio: ['ignore', 'pipe', 'pipe'],
610
+ ...(0, process_tracker_1.OwnedSpawnOptions)(),
370
611
  });
612
+ // the background instance the client can start stays reachable through this record
613
+ (0, process_tracker_1.TrackOwnedProcess)(child, startedAt);
371
614
  const sigintHandler = () => child.kill('SIGINT');
372
615
  const sigtermHandler = () => child.kill('SIGTERM');
373
616
  process.once('SIGINT', sigintHandler);
374
617
  process.once('SIGTERM', sigtermHandler);
618
+ // a client that waits on something that never comes must not hold a CI job until it times out
619
+ const timer = setTimeout(() => {
620
+ timedOut = true;
621
+ child.kill('SIGKILL');
622
+ }, LICENSING_CLIENT_TIMEOUT_MS);
375
623
  let hasCleanedUpListeners = false;
376
624
  function removeListeners() {
377
625
  if (hasCleanedUpListeners) {
378
626
  return;
379
627
  }
380
628
  hasCleanedUpListeners = true;
629
+ clearTimeout(timer);
381
630
  process.removeListener('SIGINT', sigintHandler);
382
631
  process.removeListener('SIGTERM', sigtermHandler);
383
632
  }
@@ -391,17 +640,38 @@ class LicensingClient {
391
640
  removeListeners();
392
641
  reject(error);
393
642
  });
394
- child.on('close', (code) => {
643
+ // the client can start a background instance that inherits its stdout and stderr,
644
+ // so 'close' may never fire; settle on 'exit' and stop reading the pipes shortly after
645
+ let settled = false;
646
+ const settle = (code, signal) => {
647
+ if (settled) {
648
+ return;
649
+ }
650
+ settled = true;
395
651
  removeListeners();
396
- resolve(code === null ? 0 : code);
652
+ killedBy = signal;
653
+ // a client killed by a signal did not finish its work, so it is a failure, not exit code 0
654
+ resolve(timedOut ? -1 : code === null ? (signal ? -2 : 0) : code);
655
+ };
656
+ child.on('close', settle);
657
+ child.on('exit', (code, signal) => {
658
+ setTimeout(() => {
659
+ child.stdout.destroy();
660
+ child.stderr.destroy();
661
+ settle(code, signal);
662
+ }, 1000);
397
663
  });
398
664
  });
399
665
  }
400
666
  finally {
667
+ if (timedOut) {
668
+ this.logger.error(`Unity Licensing Client did not finish within ${LICENSING_CLIENT_TIMEOUT_MS / 60000} minutes. Its log: ${LicensingClient.ClientLogPath()}`);
669
+ await this.printClientLogProblems(logOffset, secrets);
670
+ }
671
+ // any client output can carry emails and tokens, so it gets the same sanitizing as the client log
672
+ const maskedLines = this.maskSerialInOutput(output).split(/\r?\n/).map(line => LicensingClient.sanitizeLogLine(line, secrets));
401
673
  if (!silent || exitCode !== 0) {
402
- const maskedOutput = this.maskSerialInOutput(output);
403
- const splitLines = maskedOutput.split(/\r?\n/);
404
- for (const line of splitLines) {
674
+ for (const line of maskedLines) {
405
675
  if (line === undefined || line.length === 0) {
406
676
  continue;
407
677
  }
@@ -410,12 +680,61 @@ class LicensingClient {
410
680
  }
411
681
  this.logger.endGroup();
412
682
  if (exitCode !== 0) {
413
- const message = this.getExitCodeMessage(exitCode);
683
+ let message = timedOut ? 'Timed out' : killedBy ? `Killed by ${killedBy}` : this.getExitCodeMessage(exitCode);
684
+ // Unity ID throttles sign-ins when many jobs activate with one account at once; naming it lets the
685
+ // failure be marked for a rerun once those jobs finish
686
+ if (/maximum same user attempt retry count/i.test(output)) {
687
+ message = 'Unity ID sign-in rate limit reached: too many sign-in attempts for this account';
688
+ }
689
+ // the client's own reason, such as a seat limit, lets the failure be classified; exit 17 alone does not say
690
+ const diagnostic = LicensingClientDiagnostic(maskedLines);
691
+ if (diagnostic) {
692
+ message += `\n${diagnostic}`;
693
+ }
414
694
  throw new Error(`License command failed with exit code ${exitCode}: ${message}`);
415
695
  }
416
696
  }
417
697
  return output;
418
698
  }
699
+ /**
700
+ * Redacts the known secrets, email addresses and long token-like strings (JWTs, access and refresh tokens) from a client log line.
701
+ * The line is split on separator characters and each piece is checked on its own, which stays linear on any input.
702
+ */
703
+ static sanitizeLogLine(line, secrets) {
704
+ return (0, utilities_1.SanitizeLogText)(line, secrets);
705
+ }
706
+ /**
707
+ * Prints the last lines the client logged since `offset`, to show why a command hung.
708
+ * The log can hold account details and tokens, so every line is sanitized first.
709
+ */
710
+ async printClientLogProblems(offset, secrets, maxLines = 40) {
711
+ try {
712
+ const content = await fs.promises.readFile(LicensingClient.ClientLogPath());
713
+ const lines = content.subarray(offset).toString('utf-8').split(/\r?\n/)
714
+ .filter(line => line.trim().length > 0)
715
+ .slice(-maxLines)
716
+ .map(line => LicensingClient.sanitizeLogLine(line, secrets));
717
+ if (lines.length > 0) {
718
+ this.logger.info(`Last lines the Unity Licensing Client logged (redacted):\n${lines.join('\n')}`);
719
+ }
720
+ }
721
+ catch (error) {
722
+ this.logger.debug(`Could not read the Unity Licensing Client log: ${error}`);
723
+ }
724
+ }
725
+ /**
726
+ * Stops Unity.Licensing.Client instances that this job's unity-cli commands started, directly or through the
727
+ * official Unity CLI or an editor run. A command line activation can wait on such an instance indefinitely.
728
+ * Clients other jobs started are left alone, even on a shared self-hosted runner. Only done in CI.
729
+ */
730
+ static async StopBackgroundClients() {
731
+ if (process.env.CI !== 'true') {
732
+ return;
733
+ }
734
+ // matched on the executable's own name, so a process that only mentions it on its command line is left alone
735
+ const clients = (await (0, process_tracker_1.FindOwnedProcesses)()).filter(proc => (0, process_tracker_1.IsExecutableNamed)(proc, 'Unity.Licensing.Client'));
736
+ await Promise.all(clients.map(client => (0, utilities_1.KillProcess)(client).catch(() => undefined)));
737
+ }
419
738
  maskSerialInOutput(output) {
420
739
  return output.replace(/([\w-]+-XXXX)/g, (_, serial) => {
421
740
  const maskedSerial = serial.slice(0, -4) + `XXXX`;
@@ -529,11 +848,12 @@ class LicensingClient {
529
848
  const output = await this.exec([`--acquire-floating`], true);
530
849
  const tokenMatch = output.match(/with token:\s*"(?<token>[\w-]+)"/);
531
850
  if (!tokenMatch || !tokenMatch.groups || !tokenMatch.groups['token']) {
532
- throw new Error(`Failed to acquire floating license lease: No token found in output.\n ${output}`);
851
+ throw new Error(`Failed to acquire floating license lease: No token found in output.\n ${(0, utilities_1.SanitizeLogText)(output)}`);
533
852
  }
534
853
  const token = tokenMatch.groups['token'];
535
854
  this.logger.CI_mask(token);
536
- this.logger.info(output);
855
+ // the token is returned to the caller (and printed with --json); the log never shows it
856
+ this.logger.info((0, utilities_1.redactSensitiveLiterals)(output, [token]));
537
857
  return token;
538
858
  }
539
859
  default: { // personal and professional license activation
@@ -604,6 +924,20 @@ class LicensingClient {
604
924
  }
605
925
  return licenses;
606
926
  }
927
+ /**
928
+ * The machine binding values the licensing client reports for this machine (Legacy.MachineBinding1, 2, ...).
929
+ * @returns The values, or an empty list when the client cannot report them.
930
+ */
931
+ async GetMachineBindings() {
932
+ try {
933
+ const context = await this.exec(['--showContext'], true);
934
+ return [...context.matchAll(/Legacy\.MachineBinding\d+[ \t]+(\S+)/g)].map(match => match[1]);
935
+ }
936
+ catch (error) {
937
+ this.logger.debug(`Failed to read the machine bindings: ${error}`);
938
+ return [];
939
+ }
940
+ }
607
941
  async activateLicense(licenseType, username, password, serial = undefined) {
608
942
  const args = [
609
943
  `--activate-ulf`,
@@ -619,13 +953,37 @@ class LicensingClient {
619
953
  if (licenseType === LicenseType.personal) {
620
954
  args.push(`--include-personal`);
621
955
  }
956
+ // keep redacting the account details in every later command's output, such as --showEntitlements
957
+ this.secrets.push(username, password, ...(serial ? [serial, serial.slice(0, -4) + 'XXXX'] : []));
622
958
  await this.exec(args);
623
959
  const activeLicenses = await this.GetActiveEntitlements();
624
960
  if (!activeLicenses.includes(licenseType)) {
625
961
  throw new Error(`Failed to activate license of type '${licenseType}'`);
626
962
  }
963
+ await this.linkLegacyLicenseFile();
627
964
  this.logger.info(`Successfully activated license of type '${licenseType}'`);
628
965
  }
966
+ /**
967
+ * Points Unity_v4.x.ulf or Unity_v5.x.ulf at the license the client actually wrote. Clients that ignore
968
+ * UNITY_COMMON_DIR, such as the macOS one, write Unity_lic.ulf to the regular folder instead of the patched one.
969
+ */
970
+ async linkLegacyLicenseFile() {
971
+ if (!this.legacyLicense) {
972
+ return;
973
+ }
974
+ const { linkPath, commonDirectory, patchedDirectory } = this.legacyLicense;
975
+ const candidates = [path.join(patchedDirectory, 'Unity_lic.ulf'), path.join(commonDirectory, 'Unity_lic.ulf')];
976
+ const licensePath = candidates.find(candidate => fs.existsSync(candidate));
977
+ if (!licensePath) {
978
+ this.logger.warn(`No Unity_lic.ulf was written to ${candidates.join(' or ')}`);
979
+ return;
980
+ }
981
+ await fs.promises.rm(linkPath, { force: true });
982
+ await fs.promises.symlink(licensePath, linkPath);
983
+ // the license version decides whether a 4.x or 5.x editor accepts the file
984
+ const licenseVersion = (await fs.promises.readFile(licensePath, 'utf-8')).match(/<LicenseVersion Value="([^"]+)"/)?.[1];
985
+ this.logger.info(`Linked ${linkPath} to ${licensePath} (license version ${licenseVersion ?? 'unknown'})`);
986
+ }
629
987
  async returnLicense(licenseType, token) {
630
988
  if (licenseType === LicenseType.floating) {
631
989
  if (!token || token.length === 0) {
@@ -651,4 +1009,327 @@ class LicensingClient {
651
1009
  }
652
1010
  }
653
1011
  exports.LicensingClient = LicensingClient;
1012
+ /**
1013
+ * Gets the license version a 4.x or 5.x editor in UNITY_EDITOR_PATH reads, or undefined for newer editors.
1014
+ * @param editorPath The editor executable. Defaults to UNITY_EDITOR_PATH.
1015
+ */
1016
+ function GetLegacyLicenseVersion(editorPath = process.env.UNITY_EDITOR_PATH) {
1017
+ // anchored per path segment, so the match stays linear on any input
1018
+ for (const segment of (editorPath ?? '').split(/[\\/ ]/)) {
1019
+ const major = /^(\d{1,4})\.\d{1,4}\.\d/.exec(segment)?.[1];
1020
+ if (major === undefined) {
1021
+ continue;
1022
+ }
1023
+ switch (major) {
1024
+ case '4':
1025
+ return '4.x';
1026
+ case '5':
1027
+ return '5.x';
1028
+ default:
1029
+ return undefined;
1030
+ }
1031
+ }
1032
+ return undefined;
1033
+ }
1034
+ /** How long one official CLI license command may run before it falls back. */
1035
+ const UNITY_CLI_LICENSE_TIMEOUT_MS = 3 * 60 * 1000;
1036
+ /**
1037
+ * Activates a Unity license through the official Unity CLI first.
1038
+ * Falls back to the Unity Licensing Client with the Unity ID email and password when the CLI fails,
1039
+ * for example a personal license on a headless runner with no browser sign-in.
1040
+ * 4.x and 5.x editors read their own license file, so they always use the Unity Licensing Client.
1041
+ * @param options The activation options.
1042
+ * @returns The floating license token, when one is known.
1043
+ */
1044
+ async function ActivateLicense(options) {
1045
+ const logger = logging_1.Logger.instance;
1046
+ const legacyLicenseVersion = GetLegacyLicenseVersion();
1047
+ if (legacyLicenseVersion) {
1048
+ logger.info(`Unity ${legacyLicenseVersion} editors read their own license file, activating with the Unity Licensing Client...`);
1049
+ await LicensingClient.StopBackgroundClients();
1050
+ return await new LicensingClient(legacyLicenseVersion).Activate(options);
1051
+ }
1052
+ const licenseFile = options.licenseFile ?? process.env.UNITY_LICENSE;
1053
+ if (licenseFile && licenseFile.trim().length > 0 &&
1054
+ (options.licenseType === LicenseType.personal || options.licenseType === LicenseType.professional)) {
1055
+ try {
1056
+ if (await activateFromLicenseFile(licenseFile)) {
1057
+ // lets a CI step tell whether it needs to store a fresh license
1058
+ logger.CI_setOutput('license-source', 'license-file');
1059
+ return undefined;
1060
+ }
1061
+ }
1062
+ catch (error) {
1063
+ logger.warn(`The license file could not be used, activating with the account instead.\n${error}`);
1064
+ }
1065
+ }
1066
+ logger.CI_setOutput('license-source', 'account');
1067
+ let cliError;
1068
+ try {
1069
+ const result = await activateWithUnityCli(options);
1070
+ if (result.activated) {
1071
+ await logLicenseBinding();
1072
+ return result.token;
1073
+ }
1074
+ cliError = result.reason;
1075
+ }
1076
+ catch (error) {
1077
+ cliError = error;
1078
+ }
1079
+ logger.warn(`Unity CLI license activation failed, falling back to the Unity Licensing Client.\n${cliError}`);
1080
+ if (options.licenseType !== LicenseType.floating &&
1081
+ !options.username && !process.env.UNITY_USERNAME_BASE64) {
1082
+ throw new Error(`Unity CLI license activation failed, and no Unity ID email and password were given for the fallback.\n${cliError}`);
1083
+ }
1084
+ await LicensingClient.StopBackgroundClients();
1085
+ const token = await new LicensingClient().Activate(options);
1086
+ await logLicenseBinding();
1087
+ return token;
1088
+ }
1089
+ /**
1090
+ * Logs whether the license file now on disk names this machine's binding, which is what a cached copy is checked
1091
+ * against on the next run. Only a yes or no is logged, never the binding or the file.
1092
+ */
1093
+ async function logLicenseBinding() {
1094
+ try {
1095
+ const content = await fs.promises.readFile(GetLicenseFilePath(), 'utf-8');
1096
+ const bindings = await new LicensingClient().GetMachineBindings();
1097
+ if (bindings.length > 0) {
1098
+ logging_1.Logger.instance.info(`The license file is bound to this machine's identity: ${bindings.some(binding => content.includes(binding)) ? 'yes' : 'no'}`);
1099
+ }
1100
+ }
1101
+ catch (error) {
1102
+ logging_1.Logger.instance.info(`Could not compare the license file with this machine's identity: ${error instanceof Error ? error.message : error}`);
1103
+ }
1104
+ }
1105
+ /**
1106
+ * Whether this is a GitHub-hosted macOS or Linux runner. Those report one machine identity to Unity across every job
1107
+ * and repo, so returning a license there revokes it for all of them. Windows and self-hosted runners have their own.
1108
+ */
1109
+ function IsSharedRunnerIdentity() {
1110
+ return process.env.RUNNER_ENVIRONMENT === 'github-hosted' && (process.platform === 'darwin' || process.platform === 'linux');
1111
+ }
1112
+ /**
1113
+ * Returns a Unity license through the official Unity CLI first, then the Unity Licensing Client.
1114
+ * @param licenseType The type of license to return.
1115
+ * @param token The floating license token from activation. A floating license with a token is returned by the Unity Licensing Client.
1116
+ * @param when `auto` (the default) skips returning a personal or professional license on a shared runner identity.
1117
+ */
1118
+ async function ReturnLicense(licenseType, token = undefined, when = 'auto') {
1119
+ try {
1120
+ if (when === 'never') {
1121
+ logging_1.Logger.instance.info('Skipping the license return (--when never).');
1122
+ }
1123
+ else if (when === 'auto' && licenseType !== LicenseType.floating && IsSharedRunnerIdentity()) {
1124
+ logging_1.Logger.instance.info('Skipping the license return: GitHub-hosted macOS and Linux runners share one machine identity, so a return would revoke the license for every job and repo on it. Use --when always to return anyway.');
1125
+ }
1126
+ else {
1127
+ await returnLicense(licenseType, token);
1128
+ }
1129
+ }
1130
+ finally {
1131
+ // returning is the last licensing step of a job, so stop what the licensing calls left running in the background
1132
+ await LicensingClient.StopBackgroundClients();
1133
+ await stopUnityCliHelpers();
1134
+ }
1135
+ }
1136
+ /**
1137
+ * Stops background processes the official Unity CLI left running from its own folder, when this job's unity-cli
1138
+ * commands started them. Only done in CI.
1139
+ */
1140
+ async function stopUnityCliHelpers() {
1141
+ const cliPath = process.env.UNITY_CLI_PATH;
1142
+ if (process.env.CI !== 'true' || !cliPath) {
1143
+ return;
1144
+ }
1145
+ const cliDirectory = path.dirname(cliPath);
1146
+ const helpers = (await (0, process_tracker_1.FindOwnedProcesses)())
1147
+ .filter(proc => (0, process_tracker_1.IsPathUnder)(proc.executablePath ?? proc.commandLine.split(' ')[0] ?? '', cliDirectory));
1148
+ await Promise.all(helpers.map(helper => (0, utilities_1.KillProcess)(helper).catch(() => undefined)));
1149
+ }
1150
+ async function returnLicense(licenseType, token) {
1151
+ const logger = logging_1.Logger.instance;
1152
+ const legacyLicenseVersion = GetLegacyLicenseVersion();
1153
+ if (legacyLicenseVersion || (licenseType === LicenseType.floating && token)) {
1154
+ await LicensingClient.StopBackgroundClients();
1155
+ await new LicensingClient(legacyLicenseVersion).Deactivate(licenseType, token);
1156
+ return;
1157
+ }
1158
+ try {
1159
+ const cli = await unity_cli_1.UnityCli.Get();
1160
+ const { exitCode, envelope } = await cli.RunJson(['license', 'return', '--yes'], { timeoutMs: UNITY_CLI_LICENSE_TIMEOUT_MS });
1161
+ if (exitCode === 0 && envelope?.success !== false && !(await isLicenseActive(cli))) {
1162
+ logger.info(`Successfully returned license of type '${licenseType}'`);
1163
+ return;
1164
+ }
1165
+ logger.warn(`unity license return exited with code ${exitCode}${formatEnvelopeErrors(envelope)}, falling back to the Unity Licensing Client...`);
1166
+ }
1167
+ catch (error) {
1168
+ logger.warn(`Unity CLI license return failed, falling back to the Unity Licensing Client.\n${error}`);
1169
+ }
1170
+ if (licenseType === LicenseType.floating) {
1171
+ throw new Error('The Unity CLI could not return the floating license. Pass the token from activation to return it with the Unity Licensing Client.');
1172
+ }
1173
+ await LicensingClient.StopBackgroundClients();
1174
+ await new LicensingClient().Deactivate(licenseType);
1175
+ }
1176
+ /**
1177
+ * Prints the license state from the official Unity CLI, then the Unity Licensing Client context.
1178
+ */
1179
+ async function PrintLicenseContext() {
1180
+ try {
1181
+ const cli = await unity_cli_1.UnityCli.Get();
1182
+ await cli.Run(['license', 'status'], { stream: true });
1183
+ }
1184
+ catch (error) {
1185
+ logging_1.Logger.instance.warn(`Failed to read the Unity CLI license status: ${error}`);
1186
+ }
1187
+ await new LicensingClient(GetLegacyLicenseVersion()).Context();
1188
+ }
1189
+ /**
1190
+ * Gets where Unity reads the machine's license file, Unity_lic.ulf.
1191
+ */
1192
+ function GetLicenseFilePath() {
1193
+ switch (process.platform) {
1194
+ case 'win32':
1195
+ return path.join(process.env.PROGRAMDATA || 'C:\\ProgramData', 'Unity', 'Unity_lic.ulf');
1196
+ case 'darwin':
1197
+ return path.join('/Library', 'Application Support', 'Unity', 'Unity_lic.ulf');
1198
+ default:
1199
+ return path.join(process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'), 'unity3d', 'Unity', 'Unity_lic.ulf');
1200
+ }
1201
+ }
1202
+ /**
1203
+ * Activates from a cached license file: `unity license activate --file` first, then by placing the file where Unity reads it.
1204
+ * The file's contents are never logged.
1205
+ * @param licenseFile A path to the license file, its raw XML, or base64.
1206
+ * @returns True when a license is active afterwards.
1207
+ */
1208
+ async function activateFromLicenseFile(licenseFile) {
1209
+ const logger = logging_1.Logger.instance;
1210
+ const trimmed = licenseFile.trim();
1211
+ let content;
1212
+ if (!trimmed.startsWith('<') && fs.existsSync(trimmed)) {
1213
+ content = await fs.promises.readFile(trimmed, 'utf-8');
1214
+ }
1215
+ else if (trimmed.startsWith('<')) {
1216
+ content = trimmed;
1217
+ }
1218
+ else if (/\.ulf$/i.test(trimmed) || /^([A-Za-z]:[\\/]|[\\/~.])/.test(trimmed)) {
1219
+ // base64 of a license starts with "PD" or "PE", so this is a path to a file that is not there
1220
+ throw new Error(`The license file was not found: ${trimmed}`);
1221
+ }
1222
+ else {
1223
+ content = Buffer.from(trimmed, 'base64').toString('utf-8');
1224
+ }
1225
+ if (!content.includes('<License') || !content.includes('</root>')) {
1226
+ throw new Error('The license file is not a Unity license (Unity_lic.ulf).');
1227
+ }
1228
+ // a license file is bound to the machine it was issued to: it still activates offline on another machine, but the
1229
+ // licensing servers resolve no entitlements for it, and the editor fails later. Runner images change that identity.
1230
+ const bindings = await new LicensingClient().GetMachineBindings();
1231
+ if (bindings.length > 0 && !bindings.some(binding => content.includes(binding))) {
1232
+ logger.warn('The cached license file was issued to a different machine identity, likely before a runner image change. Activating with the account instead.');
1233
+ return false;
1234
+ }
1235
+ logger.info('Activating from the cached license file...');
1236
+ const tempDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'unity-license-'));
1237
+ const tempFile = path.join(tempDirectory, 'Unity_lic.ulf');
1238
+ try {
1239
+ await fs.promises.writeFile(tempFile, content, { encoding: 'utf-8', mode: 0o600 });
1240
+ try {
1241
+ const cli = await unity_cli_1.UnityCli.Get();
1242
+ const { exitCode, envelope } = await cli.RunJson(['license', 'activate', '--file', tempFile], { timeoutMs: UNITY_CLI_LICENSE_TIMEOUT_MS });
1243
+ if (exitCode === 0 && envelope?.success !== false && await isLicenseActive(cli)) {
1244
+ logger.info('Successfully activated from the cached license file with the Unity CLI');
1245
+ return true;
1246
+ }
1247
+ logger.warn(`unity license activate --file exited with code ${exitCode}${formatEnvelopeErrors(envelope)}, placing the license file directly...`);
1248
+ }
1249
+ catch (error) {
1250
+ logger.warn(`Unity CLI activation from the license file failed, placing the license file directly...\n${error}`);
1251
+ }
1252
+ const licensePath = GetLicenseFilePath();
1253
+ await fs.promises.mkdir(path.dirname(licensePath), { recursive: true });
1254
+ await fs.promises.copyFile(tempFile, licensePath);
1255
+ const active = await new LicensingClient().GetActiveEntitlements();
1256
+ if (active.length > 0) {
1257
+ logger.info(`Successfully activated from the cached license file (${active.join(', ')})`);
1258
+ return true;
1259
+ }
1260
+ return false;
1261
+ }
1262
+ finally {
1263
+ await fs.promises.rm(tempDirectory, { recursive: true, force: true });
1264
+ }
1265
+ }
1266
+ async function activateWithUnityCli(options) {
1267
+ const args = ['license', 'activate'];
1268
+ const redactLiterals = [];
1269
+ switch (options.licenseType) {
1270
+ case LicenseType.personal:
1271
+ args.push('--personal', '--accept-eula');
1272
+ break;
1273
+ case LicenseType.professional:
1274
+ if (!options.serial) {
1275
+ return { activated: false, reason: 'A serial is required to activate a professional license with the Unity CLI.' };
1276
+ }
1277
+ args.push('--serial', options.serial.trim());
1278
+ redactLiterals.push(options.serial.trim());
1279
+ break;
1280
+ case LicenseType.floating:
1281
+ if (!options.servicesConfig) {
1282
+ throw new Error('Services config path is required for floating license activation');
1283
+ }
1284
+ await new LicensingClient().setupServicesConfig(options.servicesConfig);
1285
+ args.push('--floating');
1286
+ break;
1287
+ }
1288
+ const cli = await unity_cli_1.UnityCli.Get();
1289
+ logging_1.Logger.instance.info(`Activating a ${options.licenseType} license with the Unity CLI...`);
1290
+ const { exitCode, envelope } = await cli.RunJson(args, { timeoutMs: UNITY_CLI_LICENSE_TIMEOUT_MS, redactLiterals });
1291
+ if (exitCode !== 0 || envelope?.success === false) {
1292
+ return { activated: false, reason: `unity license activate exited with code ${exitCode}${formatEnvelopeErrors(envelope)}` };
1293
+ }
1294
+ if (!(await isLicenseActive(cli))) {
1295
+ return { activated: false, reason: 'unity license activate succeeded, but unity license status reports no active license' };
1296
+ }
1297
+ const token = findToken(envelope?.data);
1298
+ if (token) {
1299
+ logging_1.Logger.instance.CI_mask(token);
1300
+ }
1301
+ logging_1.Logger.instance.info(`Successfully activated license of type '${options.licenseType}' with the Unity CLI`);
1302
+ return { activated: true, token };
1303
+ }
1304
+ /**
1305
+ * Reads whether a license is active. Throws when the CLI gives no clear answer (a timeout or an unparseable result),
1306
+ * so callers fall back instead of assuming the license is gone. LICENSE_NOT_ACTIVE exits 4 with active: false.
1307
+ */
1308
+ async function isLicenseActive(cli) {
1309
+ const { exitCode, envelope } = await cli.RunJson(['license', 'status'], { timeoutMs: UNITY_CLI_LICENSE_TIMEOUT_MS });
1310
+ const active = envelope?.data?.active;
1311
+ if ((exitCode === 0 || exitCode === 4) && typeof active === 'boolean') {
1312
+ return active;
1313
+ }
1314
+ throw new Error(`unity license status exited with code ${exitCode} without a license state${formatEnvelopeErrors(envelope)}`);
1315
+ }
1316
+ function findToken(value) {
1317
+ if (!value || typeof value !== 'object') {
1318
+ return undefined;
1319
+ }
1320
+ for (const [key, item] of Object.entries(value)) {
1321
+ if (typeof item === 'string' && /token/i.test(key) && item.length > 0) {
1322
+ return item;
1323
+ }
1324
+ const nested = findToken(item);
1325
+ if (nested) {
1326
+ return nested;
1327
+ }
1328
+ }
1329
+ return undefined;
1330
+ }
1331
+ function formatEnvelopeErrors(envelope) {
1332
+ const errors = envelope?.errors ?? [];
1333
+ return errors.length > 0 ? `\n${errors.map(e => ` > ${e.code}: ${e.message}`).join('\n')}` : '';
1334
+ }
654
1335
  //# sourceMappingURL=license-client.js.map