@hanzo/runtime 0.0.0-dev

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.
package/src/Image.ts ADDED
@@ -0,0 +1,643 @@
1
+ /*
2
+ * Copyright 2025 Daytona Platforms Inc.
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ import * as fs from 'fs'
7
+ import * as fg from 'fast-glob'
8
+ import * as _path from 'path'
9
+ import { quote, parse as parseShellQuote } from 'shell-quote'
10
+ import expandTilde from 'expand-tilde'
11
+ import { DaytonaError } from './errors/DaytonaError'
12
+ import { parse as parseToml } from '@iarna/toml'
13
+
14
+ const SUPPORTED_PYTHON_SERIES = ['3.9', '3.10', '3.11', '3.12', '3.13'] as const
15
+ type SupportedPythonSeries = (typeof SUPPORTED_PYTHON_SERIES)[number]
16
+ const LATEST_PYTHON_MICRO_VERSIONS = ['3.9.22', '3.10.17', '3.11.12', '3.12.10', '3.13.3']
17
+
18
+ /**
19
+ * Represents a context file to be added to the image.
20
+ *
21
+ * @interface
22
+ * @property {string} sourcePath - The path to the source file or directory.
23
+ * @property {string} archivePath - The path inside the archive file in object storage.
24
+ */
25
+ export interface Context {
26
+ sourcePath: string
27
+ archivePath: string
28
+ }
29
+
30
+ /**
31
+ * Options for the pip install command.
32
+ *
33
+ * @interface
34
+ * @property {string[]} findLinks - The find-links to use for the pip install command.
35
+ * @property {string} indexUrl - The index URL to use for the pip install command.
36
+ * @property {string[]} extraIndexUrls - The extra index URLs to use for the pip install command.
37
+ * @property {boolean} pre - Whether to install pre-release versions.
38
+ * @property {string} extraOptions - The extra options to use for the pip install command. Given string is passed directly to the pip install command.
39
+ */
40
+ export interface PipInstallOptions {
41
+ findLinks?: string[]
42
+ indexUrl?: string
43
+ extraIndexUrls?: string[]
44
+ pre?: boolean
45
+ extraOptions?: string
46
+ }
47
+
48
+ /**
49
+ * Options for the pip install command from a pyproject.toml file.
50
+ *
51
+ * @interface
52
+ * @property {string[]} optionalDependencies - The optional dependencies to install.
53
+ *
54
+ * @extends {PipInstallOptions}
55
+ */
56
+ export interface PyprojectOptions extends PipInstallOptions {
57
+ optionalDependencies?: string[]
58
+ }
59
+
60
+ /**
61
+ * Represents an image definition for a Daytona sandbox.
62
+ * Do not construct this class directly. Instead use one of its static factory methods,
63
+ * such as `Image.base()`, `Image.debianSlim()` or `Image.fromDockerfile()`.
64
+ *
65
+ * @class
66
+ * @property {string} dockerfile - The Dockerfile content.
67
+ * @property {Context[]} contextList - The list of context files to be added to the image.
68
+ */
69
+ export class Image {
70
+ private _dockerfile = ''
71
+ private _contextList: Context[] = []
72
+
73
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
74
+ private constructor() {}
75
+
76
+ get dockerfile(): string {
77
+ return this._dockerfile
78
+ }
79
+
80
+ get contextList(): Context[] {
81
+ return this._contextList
82
+ }
83
+
84
+ /**
85
+ * Adds commands to install packages using pip.
86
+ *
87
+ * @param {string | string[]} packages - The packages to install.
88
+ * @param {Object} options - The options for the pip install command.
89
+ * @param {string[]} options.findLinks - The find-links to use for the pip install command.
90
+ * @returns {Image} The Image instance.
91
+ *
92
+ * @example
93
+ * const image = Image.debianSlim('3.12').pipInstall('numpy', { findLinks: ['https://pypi.org/simple'] })
94
+ */
95
+ pipInstall(packages: string | string[], options?: PipInstallOptions): Image {
96
+ const pkgs = this.flattenStringArgs('pipInstall', 'packages', packages)
97
+ if (!pkgs.length) return this
98
+
99
+ const extraArgs = this.formatPipInstallArgs(options)
100
+ this._dockerfile += `RUN python -m pip install ${quote(pkgs.sort())}${extraArgs}\n`
101
+
102
+ return this
103
+ }
104
+
105
+ /**
106
+ * Installs dependencies from a requirements.txt file.
107
+ *
108
+ * @param {string} requirementsTxt - The path to the requirements.txt file.
109
+ * @param {PipInstallOptions} options - The options for the pip install command.
110
+ * @returns {Image} The Image instance.
111
+ *
112
+ * @example
113
+ * const image = Image.debianSlim('3.12')
114
+ * image.pipInstallFromRequirements('requirements.txt', { findLinks: ['https://pypi.org/simple'] })
115
+ */
116
+ pipInstallFromRequirements(requirementsTxt: string, options?: PipInstallOptions): Image {
117
+ const expandedPath = expandTilde(requirementsTxt)
118
+ if (!fs.existsSync(expandedPath)) {
119
+ throw new Error(`Requirements file ${requirementsTxt} does not exist`)
120
+ }
121
+
122
+ const extraArgs = this.formatPipInstallArgs(options)
123
+
124
+ this._contextList.push({ sourcePath: expandedPath, archivePath: expandedPath })
125
+ this._dockerfile += `COPY ${expandedPath} /.requirements.txt\n`
126
+ this._dockerfile += `RUN python -m pip install -r /.requirements.txt${extraArgs}\n`
127
+
128
+ return this
129
+ }
130
+
131
+ /**
132
+ * Installs dependencies from a pyproject.toml file.
133
+ *
134
+ * @param {string} pyprojectToml - The path to the pyproject.toml file.
135
+ * @param {PyprojectOptions} options - The options for the pip install command.
136
+ * @returns {Image} The Image instance.
137
+ *
138
+ * @example
139
+ * const image = Image.debianSlim('3.12')
140
+ * image.pipInstallFromPyproject('pyproject.toml', { optionalDependencies: ['dev'] })
141
+ */
142
+ pipInstallFromPyproject(pyprojectToml: string, options?: PyprojectOptions): Image {
143
+ const tomlData = parseToml(fs.readFileSync(expandTilde(pyprojectToml), 'utf-8')) as any
144
+ const dependencies: string[] = []
145
+
146
+ if (!tomlData || !tomlData.project || !Array.isArray(tomlData.project.dependencies)) {
147
+ const msg =
148
+ 'No [project.dependencies] section in pyproject.toml file. ' +
149
+ 'See https://packaging.python.org/en/latest/guides/writing-pyproject-toml ' +
150
+ 'for further file format guidelines.'
151
+ throw new DaytonaError(msg)
152
+ }
153
+
154
+ dependencies.push(...tomlData.project.dependencies)
155
+
156
+ if (options?.optionalDependencies && tomlData.project['optional-dependencies']) {
157
+ const optionalGroups = tomlData.project['optional-dependencies'] as Record<string, string[]>
158
+ for (const group of options.optionalDependencies) {
159
+ const deps = optionalGroups[group]
160
+ if (Array.isArray(deps)) {
161
+ dependencies.push(...deps)
162
+ }
163
+ }
164
+ }
165
+
166
+ return this.pipInstall(dependencies, options)
167
+ }
168
+
169
+ /**
170
+ * Adds a local file to the image.
171
+ *
172
+ * @param {string} localPath - The path to the local file.
173
+ * @param {string} remotePath - The path of the file in the image.
174
+ * @returns {Image} The Image instance.
175
+ *
176
+ * @example
177
+ * const image = Image
178
+ * .debianSlim('3.12')
179
+ * .addLocalFile('requirements.txt', '/home/daytona/requirements.txt')
180
+ */
181
+ addLocalFile(localPath: string, remotePath: string): Image {
182
+ if (remotePath.endsWith('/')) {
183
+ remotePath = remotePath + _path.basename(localPath)
184
+ }
185
+
186
+ const expandedPath = expandTilde(localPath)
187
+ this._contextList.push({ sourcePath: expandedPath, archivePath: expandedPath })
188
+ this._dockerfile += `COPY ${expandedPath} ${remotePath}\n`
189
+
190
+ return this
191
+ }
192
+
193
+ /**
194
+ * Adds a local directory to the image.
195
+ *
196
+ * @param {string} localPath - The path to the local directory.
197
+ * @param {string} remotePath - The path of the directory in the image.
198
+ * @returns {Image} The Image instance.
199
+ *
200
+ * @example
201
+ * const image = Image
202
+ * .debianSlim('3.12')
203
+ * .addLocalDir('src', '/home/daytona/src')
204
+ */
205
+ addLocalDir(localPath: string, remotePath: string): Image {
206
+ const expandedPath = expandTilde(localPath)
207
+
208
+ this._contextList.push({ sourcePath: expandedPath, archivePath: expandedPath })
209
+ this._dockerfile += `COPY ${expandedPath} ${remotePath}\n`
210
+
211
+ return this
212
+ }
213
+
214
+ /**
215
+ * Runs commands in the image.
216
+ *
217
+ * @param {string | string[]} commands - The commands to run.
218
+ * @returns {Image} The Image instance.
219
+ *
220
+ * @example
221
+ * const image = Image
222
+ * .debianSlim('3.12')
223
+ * .runCommands(
224
+ * 'echo "Hello, world!"',
225
+ * ['bash', '-c', 'echo Hello, world, again!']
226
+ * )
227
+ */
228
+ runCommands(...commands: (string | string[])[]): Image {
229
+ for (const command of commands) {
230
+ if (Array.isArray(command)) {
231
+ this._dockerfile += `RUN ${command.map((c) => `"${c.replace(/"/g, '\\\\\\"').replace(/'/g, "\\'")}"`).join(' ')}\n`
232
+ } else {
233
+ this._dockerfile += `RUN ${command}\n`
234
+ }
235
+ }
236
+
237
+ return this
238
+ }
239
+
240
+ /**
241
+ * Sets environment variables in the image.
242
+ *
243
+ * @param {Record<string, string>} envVars - The environment variables to set.
244
+ * @returns {Image} The Image instance.
245
+ *
246
+ * @example
247
+ * const image = Image
248
+ * .debianSlim('3.12')
249
+ * .env({ FOO: 'bar' })
250
+ */
251
+ env(envVars: Record<string, string>): Image {
252
+ const nonStringKeys = Object.entries(envVars)
253
+ .filter(([, value]) => typeof value !== 'string')
254
+ .map(([key]) => key)
255
+
256
+ if (nonStringKeys.length) {
257
+ throw new Error(`Image ENV variables must be strings. Invalid keys: ${nonStringKeys}`)
258
+ }
259
+
260
+ for (const [key, val] of Object.entries(envVars)) {
261
+ this._dockerfile += `ENV ${key}=${quote([val])}\n`
262
+ }
263
+
264
+ return this
265
+ }
266
+
267
+ /**
268
+ * Sets the working directory in the image.
269
+ *
270
+ * @param {string} dirPath - The path to the working directory.
271
+ * @returns {Image} The Image instance.
272
+ *
273
+ * @example
274
+ * const image = Image
275
+ * .debianSlim('3.12')
276
+ * .workdir('/home/daytona')
277
+ */
278
+ workdir(dirPath: string): Image {
279
+ this._dockerfile += `WORKDIR ${quote([dirPath])}\n`
280
+ return this
281
+ }
282
+
283
+ /**
284
+ * Sets the entrypoint for the image.
285
+ *
286
+ * @param {string[]} entrypointCommands - The commands to set as the entrypoint.
287
+ * @returns {Image} The Image instance.
288
+ *
289
+ * @example
290
+ * const image = Image
291
+ * .debianSlim('3.12')
292
+ * .entrypoint(['/bin/bash'])
293
+ */
294
+ entrypoint(entrypointCommands: string[]): Image {
295
+ if (!Array.isArray(entrypointCommands) || !entrypointCommands.every((x) => typeof x === 'string')) {
296
+ throw new Error('entrypoint_commands must be a list of strings')
297
+ }
298
+
299
+ const argsStr = entrypointCommands.map((arg) => `"${arg}"`).join(', ')
300
+ this._dockerfile += `ENTRYPOINT [${argsStr}]\n`
301
+
302
+ return this
303
+ }
304
+
305
+ /**
306
+ * Sets the default command for the image.
307
+ *
308
+ * @param {string[]} cmd - The command to set as the default command.
309
+ * @returns {Image} The Image instance.
310
+ *
311
+ * @example
312
+ * const image = Image
313
+ * .debianSlim('3.12')
314
+ * .cmd(['/bin/bash'])
315
+ */
316
+ cmd(cmd: string[]): Image {
317
+ if (!Array.isArray(cmd) || !cmd.every((x) => typeof x === 'string')) {
318
+ throw new Error('Image CMD must be a list of strings')
319
+ }
320
+
321
+ const cmdStr = cmd.map((arg) => `"${arg}"`).join(', ')
322
+ this._dockerfile += `CMD [${cmdStr}]\n`
323
+
324
+ return this
325
+ }
326
+
327
+ /**
328
+ * Extends an image with arbitrary Dockerfile-like commands.
329
+ *
330
+ * @param {string | string[]} dockerfileCommands - The commands to add to the Dockerfile.
331
+ * @param {string} contextDir - The path to the context directory.
332
+ * @returns {Image} The Image instance.
333
+ *
334
+ * @example
335
+ * const image = Image
336
+ * .debianSlim('3.12')
337
+ * .dockerfileCommands(['RUN echo "Hello, world!"'])
338
+ */
339
+ dockerfileCommands(dockerfileCommands: string[], contextDir?: string): Image {
340
+ if (contextDir) {
341
+ const expandedPath = expandTilde(contextDir)
342
+ if (!fs.existsSync(expandedPath) || !fs.statSync(expandedPath).isDirectory()) {
343
+ throw new Error(`Context directory ${contextDir} does not exist`)
344
+ }
345
+ }
346
+
347
+ for (const [contextPath, originalPath] of Image.extractCopySources(
348
+ dockerfileCommands.join('\n'),
349
+ contextDir || '',
350
+ )) {
351
+ let archiveBasePath = contextPath
352
+ if (contextDir && !originalPath.startsWith(contextDir)) {
353
+ archiveBasePath = contextPath.substring(contextDir.length)
354
+ // Remove leading separators
355
+ // eslint-disable-next-line no-useless-escape
356
+ archiveBasePath = archiveBasePath.replace(/^[\/\\]+/, '')
357
+ }
358
+ this._contextList.push({ sourcePath: contextPath, archivePath: archiveBasePath })
359
+ }
360
+
361
+ this._dockerfile += dockerfileCommands.join('\n') + '\n'
362
+ return this
363
+ }
364
+
365
+ /**
366
+ * Creates an Image from an existing Dockerfile.
367
+ *
368
+ * @param {string} path - The path to the Dockerfile.
369
+ * @returns {Image} The Image instance.
370
+ *
371
+ * @example
372
+ * const image = Image.fromDockerfile('Dockerfile')
373
+ */
374
+ static fromDockerfile(path: string): Image {
375
+ const expandedPath = _path.resolve(expandTilde(path))
376
+ if (!fs.existsSync(expandedPath)) {
377
+ throw new Error(`Dockerfile ${path} does not exist`)
378
+ }
379
+
380
+ const dockerfileContent = fs.readFileSync(expandedPath, 'utf-8')
381
+ const img = new Image()
382
+ img._dockerfile = dockerfileContent
383
+
384
+ // Remove dockerfile filename from path to get the path prefix
385
+ const pathPrefix = _path.dirname(expandedPath) + _path.sep
386
+
387
+ for (const [contextPath, originalPath] of Image.extractCopySources(dockerfileContent, pathPrefix)) {
388
+ let archiveBasePath = contextPath
389
+ if (!originalPath.startsWith(pathPrefix)) {
390
+ // Remove the path prefix from the context path to get the archive path
391
+ archiveBasePath = contextPath.substring(pathPrefix.length)
392
+ // Remove leading separators
393
+ // eslint-disable-next-line no-useless-escape
394
+ archiveBasePath = archiveBasePath.replace(/^[\/\\]+/, '')
395
+ }
396
+ img._contextList.push({ sourcePath: contextPath, archivePath: archiveBasePath })
397
+ }
398
+
399
+ return img
400
+ }
401
+
402
+ /**
403
+ * Creates an Image from an existing base image.
404
+ *
405
+ * @param {string} image - The base image to use.
406
+ * @returns {Image} The Image instance.
407
+ *
408
+ * @example
409
+ * const image = Image.base('python:3.12-slim-bookworm')
410
+ */
411
+ static base(image: string): Image {
412
+ const img = new Image()
413
+ img._dockerfile = `FROM ${image}\n`
414
+ return img
415
+ }
416
+
417
+ /**
418
+ * Creates a Debian slim image based on the official Python Docker image.
419
+ *
420
+ * @param {string} pythonVersion - The Python version to use.
421
+ * @returns {Image} The Image instance.
422
+ *
423
+ * @example
424
+ * const image = Image.debianSlim('3.12')
425
+ */
426
+ static debianSlim(pythonVersion?: SupportedPythonSeries): Image {
427
+ const version = Image.processPythonVersion(pythonVersion)
428
+ const img = new Image()
429
+
430
+ const commands = [
431
+ `FROM python:${version}-slim-bookworm`,
432
+ 'RUN apt-get update',
433
+ 'RUN apt-get install -y gcc gfortran build-essential',
434
+ 'RUN pip install --upgrade pip',
435
+ // Set debian front-end to non-interactive to avoid users getting stuck with input prompts.
436
+
437
+ "RUN echo 'debconf debconf/frontend select Noninteractive' | debconf-set-selections",
438
+ ]
439
+
440
+ img._dockerfile = commands.join('\n') + '\n'
441
+ return img
442
+ }
443
+
444
+ /**
445
+ * Formats pip install arguments in a single string.
446
+ *
447
+ * @param {PipInstallOptions} options - The options for the pip install command.
448
+ * @returns {string} The formatted pip install arguments.
449
+ */
450
+ private formatPipInstallArgs(options?: PipInstallOptions): string {
451
+ if (!options) return ''
452
+
453
+ let extraArgs = ''
454
+
455
+ if (options.findLinks) {
456
+ for (const findLink of options.findLinks) {
457
+ extraArgs += ` --find-links ${quote([findLink])}`
458
+ }
459
+ }
460
+
461
+ if (options.indexUrl) {
462
+ extraArgs += ` --index-url ${quote([options.indexUrl])}`
463
+ }
464
+
465
+ if (options.extraIndexUrls) {
466
+ for (const extraIndexUrl of options.extraIndexUrls) {
467
+ extraArgs += ` --extra-index-url ${quote([extraIndexUrl])}`
468
+ }
469
+ }
470
+
471
+ if (options.pre) {
472
+ extraArgs += ' --pre'
473
+ }
474
+
475
+ if (options.extraOptions) {
476
+ extraArgs += ` ${options.extraOptions.trim()}`
477
+ }
478
+
479
+ return extraArgs
480
+ }
481
+
482
+ /**
483
+ * Flattens a string argument.
484
+ *
485
+ * @param {string} functionName - The name of the function.
486
+ * @param {string} argName - The name of the argument.
487
+ * @param {any} args - The argument to flatten.
488
+ * @returns {string[]} The flattened argument.
489
+ */
490
+ private flattenStringArgs(functionName: string, argName: string, args: any): string[] {
491
+ const result: string[] = []
492
+
493
+ const flatten = (arg: any) => {
494
+ if (typeof arg === 'string') {
495
+ result.push(arg)
496
+ } else if (Array.isArray(arg)) {
497
+ for (const item of arg) {
498
+ flatten(item)
499
+ }
500
+ } else {
501
+ throw new Error(`${functionName}: ${argName} must only contain strings`)
502
+ }
503
+ }
504
+
505
+ flatten(args)
506
+ return result
507
+ }
508
+
509
+ /**
510
+ * Processes the Python version.
511
+ *
512
+ * @param {string} pythonVersion - The Python version to use.
513
+ * @returns {string} The processed Python version.
514
+ */
515
+ private static processPythonVersion(pythonVersion?: SupportedPythonSeries): string {
516
+ if (!pythonVersion) {
517
+ // Default to latest
518
+ pythonVersion = SUPPORTED_PYTHON_SERIES[SUPPORTED_PYTHON_SERIES.length - 1]
519
+ }
520
+
521
+ if (!SUPPORTED_PYTHON_SERIES.includes(pythonVersion)) {
522
+ throw new Error(
523
+ `Unsupported Python version: ${pythonVersion}. ` +
524
+ `Daytona supports the following series: ${SUPPORTED_PYTHON_SERIES.join(', ')}`,
525
+ )
526
+ }
527
+
528
+ // Map series to latest micro version
529
+ const seriesMap = Object.fromEntries(
530
+ LATEST_PYTHON_MICRO_VERSIONS.map((v) => {
531
+ const [major, minor, micro] = v.split('.')
532
+ return [`${major}.${minor}`, micro]
533
+ }),
534
+ )
535
+
536
+ const micro = seriesMap[pythonVersion]
537
+ return `${pythonVersion}.${micro}`
538
+ }
539
+
540
+ /**
541
+ * Extracts source files from COPY commands in a Dockerfile.
542
+ *
543
+ * @param {string} dockerfileContent - The content of the Dockerfile.
544
+ * @param {string} pathPrefix - The path prefix to use for the sources.
545
+ * @returns {Array<[string, string]>} The list of the actual file path and its corresponding COPY-command source path.
546
+ */
547
+ private static extractCopySources(dockerfileContent: string, pathPrefix = ''): Array<[string, string]> {
548
+ const sources: Array<[string, string]> = []
549
+ const lines = dockerfileContent.split('\n')
550
+
551
+ for (const line of lines) {
552
+ // Skip empty lines and comments
553
+ if (!line.trim() || line.trim().startsWith('#')) {
554
+ continue
555
+ }
556
+
557
+ // Check if the line contains a COPY command
558
+ if (/^\s*COPY\s/.test(line)) {
559
+ const commandParts = this.parseCopyCommand(line)
560
+
561
+ if (commandParts) {
562
+ // Get source paths from the parsed command parts
563
+ for (const source of commandParts.sources) {
564
+ // Handle absolute and relative paths differently
565
+ const fullPathPattern = _path.isAbsolute(source) ? source : _path.join(pathPrefix, source)
566
+
567
+ const matchingFiles = fg.sync([fullPathPattern], { dot: true })
568
+ if (matchingFiles.length > 0) {
569
+ for (const matchingFile of matchingFiles) {
570
+ sources.push([matchingFile, source])
571
+ }
572
+ } else {
573
+ sources.push([fullPathPattern, source])
574
+ }
575
+ }
576
+ }
577
+ }
578
+ }
579
+
580
+ return sources
581
+ }
582
+
583
+ /**
584
+ * Parses a COPY command to extract sources and destination.
585
+ *
586
+ * @param {string} line - The line to parse.
587
+ * @returns {Object} The parsed sources and destination.
588
+ */
589
+ private static parseCopyCommand(line: string): { sources: string[]; dest: string } | null {
590
+ // Remove initial "COPY" and strip whitespace
591
+ const parts = line.trim().substring(4).trim()
592
+
593
+ // Handle JSON array format: COPY ["src1", "src2", "dest"]
594
+ if (parts.startsWith('[')) {
595
+ try {
596
+ // Parse the JSON-like array format
597
+ const elements = parseShellQuote(parts.replace('[', '').replace(']', '')).filter(
598
+ (x): x is string => typeof x === 'string',
599
+ )
600
+
601
+ if (elements.length < 2) {
602
+ return null
603
+ }
604
+
605
+ return {
606
+ sources: elements.slice(0, -1),
607
+ dest: elements[elements.length - 1],
608
+ }
609
+ } catch {
610
+ return null
611
+ }
612
+ }
613
+
614
+ // Handle regular format with possible flags
615
+ const splitParts = parseShellQuote(parts).filter((x): x is string => typeof x === 'string')
616
+
617
+ // Extract flags like --chown, --chmod, --from
618
+ let sourcesStartIdx = 0
619
+ for (let i = 0; i < splitParts.length; i++) {
620
+ const part = splitParts[i]
621
+ if (part.startsWith('--')) {
622
+ // Skip the flag and its value if it has one
623
+ if (!part.includes('=') && i + 1 < splitParts.length && !splitParts[i + 1].startsWith('--')) {
624
+ sourcesStartIdx = i + 2
625
+ } else {
626
+ sourcesStartIdx = i + 1
627
+ }
628
+ } else {
629
+ break
630
+ }
631
+ }
632
+
633
+ // After skipping flags, we need at least one source and one destination
634
+ if (splitParts.length - sourcesStartIdx < 2) {
635
+ return null
636
+ }
637
+
638
+ return {
639
+ sources: splitParts.slice(sourcesStartIdx, -1),
640
+ dest: splitParts[splitParts.length - 1],
641
+ }
642
+ }
643
+ }