@platforma-sdk/package-builder-lib 1.1.0

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 (134) hide show
  1. package/assets/conda-dockerfile.template +45 -0
  2. package/assets/python-dockerfile.template +22 -0
  3. package/dist/_virtual/_rolldown/runtime.cjs +33 -0
  4. package/dist/_virtual/_rolldown/runtime.js +13 -0
  5. package/dist/core/archive.cjs +52 -0
  6. package/dist/core/archive.cjs.map +1 -0
  7. package/dist/core/archive.js +47 -0
  8. package/dist/core/archive.js.map +1 -0
  9. package/dist/core/conda/builder.cjs +226 -0
  10. package/dist/core/conda/builder.cjs.map +1 -0
  11. package/dist/core/conda/builder.js +220 -0
  12. package/dist/core/conda/builder.js.map +1 -0
  13. package/dist/core/conda/validate-spec.cjs +76 -0
  14. package/dist/core/conda/validate-spec.cjs.map +1 -0
  15. package/dist/core/conda/validate-spec.js +73 -0
  16. package/dist/core/conda/validate-spec.js.map +1 -0
  17. package/dist/core/core.cjs +403 -0
  18. package/dist/core/core.cjs.map +1 -0
  19. package/dist/core/core.d.ts +82 -0
  20. package/dist/core/core.d.ts.map +1 -0
  21. package/dist/core/core.js +397 -0
  22. package/dist/core/core.js.map +1 -0
  23. package/dist/core/docker-conda.cjs +75 -0
  24. package/dist/core/docker-conda.cjs.map +1 -0
  25. package/dist/core/docker-conda.js +71 -0
  26. package/dist/core/docker-conda.js.map +1 -0
  27. package/dist/core/docker-python.cjs +147 -0
  28. package/dist/core/docker-python.cjs.map +1 -0
  29. package/dist/core/docker-python.js +144 -0
  30. package/dist/core/docker-python.js.map +1 -0
  31. package/dist/core/docker.cjs +223 -0
  32. package/dist/core/docker.cjs.map +1 -0
  33. package/dist/core/docker.js +207 -0
  34. package/dist/core/docker.js.map +1 -0
  35. package/dist/core/envs.cjs +58 -0
  36. package/dist/core/envs.cjs.map +1 -0
  37. package/dist/core/envs.d.ts +26 -0
  38. package/dist/core/envs.d.ts.map +1 -0
  39. package/dist/core/envs.js +51 -0
  40. package/dist/core/envs.js.map +1 -0
  41. package/dist/core/package-info.cjs +450 -0
  42. package/dist/core/package-info.cjs.map +1 -0
  43. package/dist/core/package-info.d.ts +57 -0
  44. package/dist/core/package-info.d.ts.map +1 -0
  45. package/dist/core/package-info.js +447 -0
  46. package/dist/core/package-info.js.map +1 -0
  47. package/dist/core/paths.cjs +15 -0
  48. package/dist/core/paths.cjs.map +1 -0
  49. package/dist/core/paths.js +14 -0
  50. package/dist/core/paths.js.map +1 -0
  51. package/dist/core/resolver.cjs +99 -0
  52. package/dist/core/resolver.cjs.map +1 -0
  53. package/dist/core/resolver.js +95 -0
  54. package/dist/core/resolver.js.map +1 -0
  55. package/dist/core/schemas/artifacts.cjs +136 -0
  56. package/dist/core/schemas/artifacts.cjs.map +1 -0
  57. package/dist/core/schemas/artifacts.d.ts +280 -0
  58. package/dist/core/schemas/artifacts.d.ts.map +1 -0
  59. package/dist/core/schemas/artifacts.js +122 -0
  60. package/dist/core/schemas/artifacts.js.map +1 -0
  61. package/dist/core/schemas/entrypoints.cjs +43 -0
  62. package/dist/core/schemas/entrypoints.cjs.map +1 -0
  63. package/dist/core/schemas/entrypoints.d.ts +32 -0
  64. package/dist/core/schemas/entrypoints.d.ts.map +1 -0
  65. package/dist/core/schemas/entrypoints.js +41 -0
  66. package/dist/core/schemas/entrypoints.js.map +1 -0
  67. package/dist/core/schemas/sw-json.cjs +111 -0
  68. package/dist/core/schemas/sw-json.cjs.map +1 -0
  69. package/dist/core/schemas/sw-json.js +110 -0
  70. package/dist/core/schemas/sw-json.js.map +1 -0
  71. package/dist/core/storage.cjs +86 -0
  72. package/dist/core/storage.cjs.map +1 -0
  73. package/dist/core/storage.js +83 -0
  74. package/dist/core/storage.js.map +1 -0
  75. package/dist/core/sw-json-render.cjs +327 -0
  76. package/dist/core/sw-json-render.cjs.map +1 -0
  77. package/dist/core/sw-json-render.js +322 -0
  78. package/dist/core/sw-json-render.js.map +1 -0
  79. package/dist/core/util.cjs +313 -0
  80. package/dist/core/util.cjs.map +1 -0
  81. package/dist/core/util.d.ts +66 -0
  82. package/dist/core/util.d.ts.map +1 -0
  83. package/dist/core/util.js +281 -0
  84. package/dist/core/util.js.map +1 -0
  85. package/dist/defaults.cjs +54 -0
  86. package/dist/defaults.cjs.map +1 -0
  87. package/dist/defaults.d.ts +21 -0
  88. package/dist/defaults.d.ts.map +1 -0
  89. package/dist/defaults.js +37 -0
  90. package/dist/defaults.js.map +1 -0
  91. package/dist/index.cjs +31 -0
  92. package/dist/index.cjs.map +1 -0
  93. package/dist/index.d.ts +20 -0
  94. package/dist/index.d.ts.map +1 -0
  95. package/dist/index.js +12 -0
  96. package/dist/index.js.map +1 -0
  97. package/package.json +56 -0
  98. package/src/__test__/Dockerfile +1 -0
  99. package/src/__test__/docker.test.ts +67 -0
  100. package/src/__test__/package.json +39 -0
  101. package/src/__test__/python-example/main.py +1 -0
  102. package/src/__test__/python-example/requirements.txt +0 -0
  103. package/src/__test__/vite.config.mts +7 -0
  104. package/src/core/__tests__/docker-conda.test.ts +145 -0
  105. package/src/core/__tests__/docker-entrypoint-generation.test.ts +119 -0
  106. package/src/core/__tests__/docker-python.test.ts +374 -0
  107. package/src/core/__tests__/publish-docker-images.test.ts +309 -0
  108. package/src/core/archive.ts +102 -0
  109. package/src/core/conda/builder.test.ts +71 -0
  110. package/src/core/conda/builder.ts +384 -0
  111. package/src/core/conda/validate-spec.test.ts +213 -0
  112. package/src/core/conda/validate-spec.ts +107 -0
  113. package/src/core/core.ts +835 -0
  114. package/src/core/docker-conda.ts +149 -0
  115. package/src/core/docker-python.ts +283 -0
  116. package/src/core/docker.ts +300 -0
  117. package/src/core/envs.ts +31 -0
  118. package/src/core/package-info.test.ts +82 -0
  119. package/src/core/package-info.ts +843 -0
  120. package/src/core/paths.ts +16 -0
  121. package/src/core/resolver.ts +195 -0
  122. package/src/core/schemas/artifacts.test.ts +72 -0
  123. package/src/core/schemas/artifacts.ts +279 -0
  124. package/src/core/schemas/entrypoints.test.ts +171 -0
  125. package/src/core/schemas/entrypoints.ts +150 -0
  126. package/src/core/schemas/sw-json.ts +216 -0
  127. package/src/core/schemas/test-artifacts.ts +362 -0
  128. package/src/core/storage.ts +125 -0
  129. package/src/core/sw-json-render.test.ts +215 -0
  130. package/src/core/sw-json-render.ts +682 -0
  131. package/src/core/util.ts +390 -0
  132. package/src/defaults.ts +38 -0
  133. package/src/index.ts +31 -0
  134. package/src/requirements.txt +1 -0
@@ -0,0 +1,102 @@
1
+ import archiver from "archiver";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import type * as winston from "winston";
5
+ import * as util from "./util";
6
+ import type { Hash } from "node:crypto";
7
+ import { createHash } from "node:crypto";
8
+
9
+ const tarArchiveType = "tgz";
10
+ const zipArchiveType = "zip";
11
+ export const allArchiveTypes = [tarArchiveType, zipArchiveType];
12
+ export type archiveType = (typeof allArchiveTypes)[number];
13
+
14
+ export type archiveOptions = {
15
+ packageRoot: string;
16
+ packageName: string;
17
+ packageVersion: string;
18
+
19
+ crossplatform: boolean;
20
+ os: util.OSType;
21
+ arch: util.ArchType;
22
+ ext: archiveType;
23
+ };
24
+
25
+ export function getPath(options: archiveOptions): string {
26
+ const packageName = options.packageName.replaceAll("/", "-").replaceAll("\\", "-");
27
+
28
+ if (options && !options.crossplatform) {
29
+ return path.resolve(
30
+ options.packageRoot,
31
+ `pkg-${packageName}-${options.packageVersion}-${options.os}-${options.arch}.${options.ext}`,
32
+ );
33
+ }
34
+
35
+ return path.resolve(
36
+ options.packageRoot,
37
+ `pkg-${packageName}-${options.packageVersion}.${options.ext}`,
38
+ );
39
+ }
40
+
41
+ export async function create(
42
+ logger: winston.Logger,
43
+ contentRoot: string,
44
+ dstArchivePath: string,
45
+ hasher?: Hash,
46
+ ): Promise<Hash> {
47
+ const compressionLevel = 9;
48
+
49
+ let format = "";
50
+ if (dstArchivePath.endsWith(".tgz") || dstArchivePath.endsWith(".tar.gz")) {
51
+ format = "tar";
52
+ }
53
+ if (dstArchivePath.endsWith(".zip")) {
54
+ format = "zip";
55
+ }
56
+
57
+ if (format == "") {
58
+ throw util.CLIError(
59
+ `Archive ${dstArchivePath} has unsupported extension. Cannot create archive of unknown format`,
60
+ );
61
+ }
62
+
63
+ const output = fs.createWriteStream(dstArchivePath);
64
+ const hash = hasher ? hasher : createHash("sha256");
65
+
66
+ const a = archiver.create(format, {
67
+ gzip: true,
68
+ gzipOptions: { level: compressionLevel },
69
+ zlib: { level: compressionLevel },
70
+ });
71
+
72
+ return new Promise((resolve, reject) => {
73
+ // Waits for the output stream to be closed.
74
+ output.on("close", function () {
75
+ logger.debug(`archive created. Total data processed: ${a.pointer()} bytes`);
76
+ resolve(hash);
77
+ });
78
+
79
+ output.on("error", function (err) {
80
+ reject(err);
81
+ });
82
+
83
+ // Catch errors
84
+ a.on("error", function (err) {
85
+ reject(err);
86
+ });
87
+
88
+ // Update hash with every chunk of data written to the archive
89
+ a.on("data", function (chunk) {
90
+ hash.update(chunk);
91
+ });
92
+
93
+ // Pipe archive data to the file
94
+ a.pipe(output);
95
+
96
+ // Append files from sourceDir without prefix
97
+ a.directory(contentRoot, false);
98
+
99
+ // Finalize the archive (i.e. we are done appending files but streams have to finish yet)
100
+ a.finalize().catch(reject);
101
+ });
102
+ }
@@ -0,0 +1,71 @@
1
+ import { test, expect } from "vitest";
2
+ import * as builder from "./builder";
3
+ import * as fsp from "node:fs/promises";
4
+ import * as path from "node:path";
5
+ import * as os from "node:os";
6
+ import * as util from "../util";
7
+
8
+ const skipCI = process.env.CI === "true";
9
+
10
+ test("download latest", async (): Promise<void> => {
11
+ const logger = util.createLogger();
12
+ if (skipCI) {
13
+ logger.info("Skipping test (not for CI)");
14
+ return;
15
+ }
16
+
17
+ const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), "test-conda-builder"));
18
+ logger.info("test directory: " + tempDir);
19
+
20
+ await builder.downloadMicromamba(logger, {
21
+ platform: util.currentPlatform(),
22
+ outputPath: path.join(tempDir, "micromamba"),
23
+ });
24
+
25
+ await fsp.rm(tempDir, { recursive: true });
26
+ });
27
+
28
+ test("cli wrapper full cycle", { timeout: 90_000 }, async (): Promise<void> => {
29
+ const logger = util.createLogger();
30
+ if (skipCI) {
31
+ logger.info("Skipping test (not for CI)");
32
+ return;
33
+ }
34
+
35
+ const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), "test-conda-builder"));
36
+ logger.info("test directory: " + tempDir);
37
+
38
+ const tool = new builder.micromamba(logger, tempDir);
39
+ await tool.downloadBinary();
40
+
41
+ const version = tool.getVersion();
42
+ expect(version.trim()).not.toEqual("");
43
+
44
+ const envSpecPath = path.join(tempDir, "env.yaml");
45
+
46
+ await fsp.writeFile(
47
+ envSpecPath,
48
+ `
49
+ name: my-env
50
+ channels:
51
+ - conda-forge
52
+ dependencies:
53
+ - python=3.12
54
+ `,
55
+ );
56
+
57
+ const envPrefix = path.join(tempDir, "my-env");
58
+ tool.createEnvironment({ environmentPrefix: envPrefix, specFile: envSpecPath });
59
+
60
+ const exportSpecPath = path.join(tempDir, "exported-env.yaml");
61
+ tool.exportEnvironment({ environmentPrefix: envPrefix, outputFile: exportSpecPath });
62
+
63
+ const exportedEnv = await fsp.readFile(exportSpecPath, "utf-8");
64
+ expect(exportedEnv).toContain("python");
65
+ expect(exportedEnv).toContain("channels:");
66
+ expect(exportedEnv).toContain("dependencies:");
67
+
68
+ tool.deleteEnvironment({ environmentPrefix: envPrefix });
69
+
70
+ await fsp.rm(tempDir, { recursive: true });
71
+ });
@@ -0,0 +1,384 @@
1
+ import fs from "node:fs";
2
+ import fsp from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { spawnSync, type SpawnSyncOptions } from "node:child_process";
5
+ import * as undici from "undici";
6
+ import type winston from "winston";
7
+
8
+ import * as util from "../util";
9
+
10
+ const defaultEnvironmentName = "default";
11
+
12
+ export interface BuilderOptions {
13
+ mambaRootPrefix: string; // root directory for all conda data
14
+ logger: winston.Logger;
15
+ micromambaVersion?: string; // use specific version of micromamba
16
+ }
17
+
18
+ export interface MicromambaDownloadOptions {
19
+ platform: util.PlatformType;
20
+ outputPath: string; // output path for the binary
21
+
22
+ version?: string; // defalut: latest
23
+ }
24
+
25
+ export interface EnvCreateOptions {
26
+ /** Environment name */
27
+ environmentName?: string;
28
+ /** Prefix where to create the environment */
29
+ environmentPrefix?: string;
30
+ /** Path to the environment specification file */
31
+ specFile: string;
32
+ }
33
+
34
+ export interface EnvExportOptions {
35
+ /** Name of the environment to export */
36
+ environmentName?: string;
37
+ /** Prefix of the environment to export */
38
+ environmentPrefix?: string;
39
+ /** Export in JSON format */
40
+ json?: boolean;
41
+ /** Output file path for the exported specification */
42
+ outputFile?: string;
43
+ }
44
+
45
+ export interface EnvDeleteOptions {
46
+ /** Environment name */
47
+ environmentName?: string;
48
+ /** Prefix where to create the environment */
49
+ environmentPrefix?: string;
50
+ }
51
+
52
+ export class micromamba {
53
+ constructor(
54
+ private readonly logger: winston.Logger,
55
+ private readonly rootPrefix: string,
56
+ private readonly binaryVersion: string = "latest",
57
+ private readonly binaryPath: string = "",
58
+ ) {
59
+ if (!binaryPath) {
60
+ this.binaryPath = path.join(rootPrefix, "micromamba");
61
+ }
62
+
63
+ if (util.currentOS() === "windows" && !this.binaryPath.endsWith(".exe")) {
64
+ this.binaryPath += ".exe";
65
+ }
66
+ }
67
+
68
+ public async downloadBinary(): Promise<void> {
69
+ if (fs.existsSync(this.binaryPath)) {
70
+ try {
71
+ await fsp.access(this.binaryPath, fs.constants.X_OK);
72
+ this.logger.debug(
73
+ `micromamba binary '${this.binaryPath}' exists and executable. Download was skipped`,
74
+ );
75
+ return;
76
+ } catch {
77
+ this.logger.debug(
78
+ `file exists but is not executable: ${this.binaryPath}. Fixing permissions`,
79
+ );
80
+ await fsp.chmod(this.binaryPath, 0o755);
81
+ }
82
+ }
83
+
84
+ await downloadMicromamba(this.logger, {
85
+ platform: util.currentPlatform(),
86
+ outputPath: this.binaryPath,
87
+ version: this.binaryVersion,
88
+ });
89
+ }
90
+
91
+ public getVersion(): string {
92
+ return this.execSync(["--version"])!;
93
+ }
94
+
95
+ public createEnvironment(opts: EnvCreateOptions): void {
96
+ const { environmentName, environmentPrefix, specFile } = opts;
97
+ this.logger.debug(
98
+ `Creating conda environment '${environmentName ?? environmentPrefix ?? defaultEnvironmentName}'...`,
99
+ );
100
+
101
+ const args = ["env", "create", "--file", specFile, "--yes"];
102
+ if (environmentPrefix) {
103
+ args.push("--prefix", environmentPrefix);
104
+ } else if (environmentName) {
105
+ args.push("--name", environmentName);
106
+ } else {
107
+ args.push("--name", defaultEnvironmentName);
108
+ }
109
+
110
+ this.execSync(args, {
111
+ stdio: "inherit",
112
+ });
113
+ }
114
+
115
+ public exportEnvironment(opts: {
116
+ environmentName?: string;
117
+ environmentPrefix?: string;
118
+ json?: boolean;
119
+ }): string;
120
+ public exportEnvironment(opts: {
121
+ environmentName?: string;
122
+ environmentPrefix?: string;
123
+ json?: boolean;
124
+ outputFile: string;
125
+ }): void;
126
+ public exportEnvironment(opts: EnvExportOptions): void | string {
127
+ const { environmentName, environmentPrefix, json, outputFile } = opts;
128
+ this.logger.debug(
129
+ `Exporting conda environment '${environmentName ?? environmentPrefix ?? defaultEnvironmentName}'...`,
130
+ );
131
+
132
+ const file = outputFile ? fs.openSync(outputFile, "w") : "pipe";
133
+
134
+ const args = ["env", "export"];
135
+ if (environmentPrefix) {
136
+ args.push("--prefix", environmentPrefix);
137
+ } else if (environmentName) {
138
+ args.push("--name", environmentName);
139
+ } else {
140
+ args.push("--name", defaultEnvironmentName);
141
+ }
142
+ if (json) {
143
+ args.push("--json");
144
+ }
145
+
146
+ try {
147
+ const envInfo = this.execSync(args, {
148
+ stdio: ["inherit", file, "inherit"],
149
+ });
150
+
151
+ if (!outputFile) {
152
+ return envInfo;
153
+ }
154
+ } catch (error) {
155
+ if (file !== "pipe") {
156
+ fs.closeSync(file);
157
+ }
158
+ if (outputFile) {
159
+ fs.rmSync(outputFile, { force: true });
160
+ }
161
+ throw error;
162
+ }
163
+
164
+ if (file !== "pipe") {
165
+ fs.closeSync(file);
166
+ }
167
+ }
168
+
169
+ public deleteEnvironment(opts: EnvDeleteOptions): void {
170
+ const { environmentName, environmentPrefix } = opts;
171
+
172
+ if (environmentPrefix) {
173
+ this.logger.debug(
174
+ `Deleting conda environment '${environmentName ?? environmentPrefix ?? defaultEnvironmentName}'...`,
175
+ );
176
+ fs.rmSync(environmentPrefix, { recursive: true });
177
+ return;
178
+ }
179
+
180
+ const result = this.exportEnvironment({ environmentName, environmentPrefix, json: true });
181
+ const envInfo = JSON.parse(result ?? "{}") as Record<string, unknown>;
182
+
183
+ if (envInfo.prefix) {
184
+ this.logger.debug(
185
+ `Deleting conda environment '${environmentName ?? environmentPrefix ?? defaultEnvironmentName}'...`,
186
+ );
187
+ fs.rmSync(envInfo.prefix as string, { recursive: true });
188
+ } else {
189
+ const errMsg =
190
+ "Failed to delete environment: cannot get environment location from micromamba tool";
191
+ this.logger.error(errMsg);
192
+ throw new Error(errMsg);
193
+ }
194
+ }
195
+
196
+ // FIXME: IMHO better to have async exec with promises. I just am not skilled enough to implement it fast. :(
197
+ private execSync(args: string[], opts?: SpawnSyncOptions): string | void {
198
+ this.logger.debug(`Executing micromamba: ${this.binaryPath} ${args.join(" ")}`);
199
+
200
+ const result = spawnSync(this.binaryPath, args, {
201
+ encoding: "utf8",
202
+ stdio: "pipe",
203
+ ...opts,
204
+ env: {
205
+ ...process.env,
206
+ MAMBA_ROOT_PREFIX: this.rootPrefix,
207
+ CONDA_PKGS_DIRS: path.join(this.rootPrefix, "pkgs"),
208
+ ...opts?.env,
209
+ },
210
+ });
211
+
212
+ if (result.error) {
213
+ throw new Error(`Failed to get micromamba version: ${result.error.message}`);
214
+ }
215
+
216
+ if (result.status !== 0) {
217
+ const errMsg = `micromamba command failed with status ${result.status}: ${result.stderr?.toString().trim() ?? "no piped stderr"}`;
218
+ this.logger.error(errMsg);
219
+ throw new Error(errMsg);
220
+ }
221
+
222
+ if (!result.stdout) {
223
+ return;
224
+ }
225
+
226
+ return result.stdout.toString().trim();
227
+ }
228
+ }
229
+
230
+ export function micromambaDownloadUrl(version: string, platform: util.PlatformType): string {
231
+ return version === "latest"
232
+ ? `https://github.com/mamba-org/micromamba-releases/releases/latest/download/${micromambaAssetName(platform)}`
233
+ : `https://github.com/mamba-org/micromamba-releases/releases/download/${version}/${micromambaAssetName(platform)}`;
234
+ }
235
+
236
+ /**
237
+ * Downloads micromamba binary for the specified platform and version
238
+ */
239
+ export async function downloadMicromamba(
240
+ logger: winston.Logger,
241
+ options: MicromambaDownloadOptions,
242
+ ): Promise<string> {
243
+ const { version = "latest", platform, outputPath } = options;
244
+
245
+ const targetPlatform = platform ?? util.currentPlatform();
246
+ const binaryPath = outputPath;
247
+
248
+ logger.debug(`Downloading micromamba version ${version} for platform ${targetPlatform}`);
249
+
250
+ const downloadUrl = micromambaDownloadUrl(version, targetPlatform);
251
+
252
+ logger.debug(`Download URL: ${downloadUrl}`);
253
+
254
+ await downloadFile(logger, downloadUrl, binaryPath);
255
+
256
+ logger.debug(`making binary executable: ${binaryPath}`);
257
+ await fsp.chmod(binaryPath, 0o755);
258
+
259
+ logger.info(`Micromamba downloaded to: ${binaryPath}`);
260
+ return binaryPath;
261
+ }
262
+
263
+ /**
264
+ * Get correct asset name with micromamba binary for particular platform,
265
+ * as they are available in micromamba-releases repo.
266
+ */
267
+ function micromambaAssetName(platform: util.PlatformType): string {
268
+ const { os, arch } = util.splitPlatform(platform);
269
+ switch (os) {
270
+ case "linux": {
271
+ switch (arch) {
272
+ case "x64":
273
+ return "micromamba-linux-64";
274
+ case "aarch64":
275
+ return "micromamba-linux-aarch64";
276
+ default:
277
+ util.assertNever(arch);
278
+ }
279
+ break;
280
+ }
281
+ case "macosx": {
282
+ switch (arch) {
283
+ case "x64":
284
+ return "micromamba-osx-64";
285
+ case "aarch64":
286
+ return "micromamba-osx-arm64";
287
+ default:
288
+ util.assertNever(arch);
289
+ }
290
+ break;
291
+ }
292
+ case "windows": {
293
+ switch (arch) {
294
+ case "x64":
295
+ return "micromamba-win-64";
296
+ case "aarch64":
297
+ throw new Error("Micromamba does not support ARM64 on Windows");
298
+ default:
299
+ util.assertNever(arch);
300
+ }
301
+ break;
302
+ }
303
+ default:
304
+ util.assertNever(os);
305
+ }
306
+ throw new Error("calm down linter, this code is unreachable");
307
+ }
308
+
309
+ /**
310
+ * Downloads a file from URL to local path
311
+ */
312
+ async function downloadFile(
313
+ logger: winston.Logger,
314
+ url: string,
315
+ outputPath: string,
316
+ ): Promise<void> {
317
+ type responseInfo = {
318
+ statusCode: number;
319
+ body: NodeJS.ReadableStream;
320
+ location: string;
321
+ };
322
+
323
+ let response: responseInfo;
324
+ const maxRedirects = 5;
325
+ const maxAttempts = 3;
326
+
327
+ let redirCount = 0;
328
+ let attempt = 0;
329
+ for (; attempt <= maxAttempts; ) {
330
+ attempt++;
331
+
332
+ logger.debug(`Trying to download: ${url}`);
333
+ try {
334
+ const requestResult = await undici.request(url, {
335
+ method: "GET",
336
+ });
337
+ response = {
338
+ statusCode: requestResult.statusCode,
339
+ location: requestResult.headers["location"]?.toString() ?? "",
340
+ body: requestResult.body,
341
+ };
342
+ } catch (error) {
343
+ const errorMessage = error instanceof Error ? error.message : String(error);
344
+ throw new Error(`Failed to make request: ${errorMessage}`);
345
+ }
346
+
347
+ if (response.statusCode === 301 || response.statusCode === 302) {
348
+ if (redirCount >= maxRedirects) {
349
+ throw new Error(`Too many redirects: ${redirCount}, last location: ${response.location}`);
350
+ }
351
+
352
+ logger.debug(`Processing redirect. New location: ${response.location}`);
353
+ redirCount++;
354
+ url = response.location;
355
+ continue;
356
+ }
357
+
358
+ if (response.statusCode === 200) {
359
+ break;
360
+ }
361
+
362
+ logger.error(`Failed to download: ${response.statusCode}, attempt ${attempt}`);
363
+ }
364
+
365
+ if (response!.statusCode !== 200) {
366
+ throw new Error(`Failed to download: last attempt status code: ${response!.statusCode}`);
367
+ }
368
+
369
+ const fileStream = fs.createWriteStream(outputPath);
370
+
371
+ response!.body.pipe(fileStream);
372
+
373
+ return new Promise((resolve, reject) => {
374
+ fileStream.on("finish", () => {
375
+ fileStream.close();
376
+ resolve();
377
+ });
378
+
379
+ fileStream.on("error", (err) => {
380
+ fs.unlink(outputPath, () => {}); // Delete the file on error
381
+ reject(err);
382
+ });
383
+ });
384
+ }