distroscript 0.1.0__py3-none-any.whl

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.
distroscript.py ADDED
@@ -0,0 +1,1163 @@
1
+ #!/usr/bin/env python3
2
+
3
+ """
4
+ Installation script generator.
5
+
6
+ Generates shell scripts for installing software packages on different Linux distributions, by
7
+ receiving a declarative YAML configuration file as input.
8
+ """
9
+
10
+ from __future__ import annotations
11
+ from dataclasses import dataclass, field, replace
12
+ from abc import ABC, abstractmethod
13
+
14
+ import argparse
15
+ import json
16
+ from jsonschema import validate, ValidationError
17
+ import os
18
+ import sys
19
+ from typing import ClassVar, Generic, TypeVar
20
+ import yaml
21
+
22
+ __version__ = "0.1.0"
23
+
24
+ def main(args: argparse.Namespace) -> None:
25
+ """
26
+ Usage: distroscript.py <config.yaml> --os <os_name> [--out <output.sh>]
27
+ """
28
+ platform = PLATFORMS.get(args.os)
29
+ if platform is None:
30
+ print(f"Error: Unsupported OS '{args.os}'. Supported OS: {', '.join(PLATFORMS.keys())}")
31
+ sys.exit(1)
32
+
33
+ try:
34
+ with open(args.config_path, 'r') as file:
35
+ config = yaml.safe_load(file)
36
+ except FileNotFoundError:
37
+ print(f"Error: Config file not found: {args.config_path}")
38
+ sys.exit(1)
39
+ except yaml.YAMLError as e:
40
+ print(f"Error: Invalid YAML in config file: {e}")
41
+ sys.exit(1)
42
+ except Exception as e:
43
+ print(f"Error: Failed to read config file: {e}")
44
+ sys.exit(1)
45
+
46
+ script_dir = os.path.dirname(os.path.abspath(__file__))
47
+ schema_path = os.path.join(script_dir, 'schema.json')
48
+ validate_config(config, schema_path)
49
+
50
+ packages = load_packages(config, platform)
51
+ resolved = resolve_packages(packages)
52
+ merged = merge_packages([
53
+ pkg.calculate_transitive_dependencies(resolved)
54
+ for pkg in resolved
55
+ ])
56
+
57
+ lines = [
58
+ "#!/usr/bin/env bash",
59
+ "",
60
+ "# -----------------------------------------------------------------------------",
61
+ "# This script was automatically generated by distroscript.",
62
+ "#",
63
+ "# This file is produced from a YAML configuration and is intended to automate",
64
+ "# the installation of software packages and dependencies for Linux systems.",
65
+ "#",
66
+ "# Any manual changes to this file will be lost if it is regenerated.",
67
+ "# To modify the installation process, edit the YAML config and regenerate.",
68
+ "#",
69
+ "# For more information, visit: https://github.com/RaniAgus/distroscript",
70
+ "# -----------------------------------------------------------------------------",
71
+ "",
72
+ "set -e",
73
+ "",
74
+ *(pkg.print() for pkg in merged)
75
+ ]
76
+
77
+ script_content = "\n".join(lines).strip() + "\n"
78
+
79
+ if args.out:
80
+ with open(args.out, 'w') as outfile:
81
+ outfile.write(script_content)
82
+ else:
83
+ print(script_content)
84
+
85
+
86
+ def validate_config(config: dict, schema_path: str) -> None:
87
+ try:
88
+ with open(schema_path, 'r') as schema_file:
89
+ schema = json.load(schema_file)
90
+
91
+ validate(instance=config, schema=schema)
92
+ except ValidationError as e:
93
+ print(f"Error: Configuration validation failed:")
94
+ print(f" Path: {' -> '.join(str(p) for p in e.path) if e.path else 'root'}")
95
+ print(f" Message: {e.message}")
96
+ sys.exit(1)
97
+ except json.JSONDecodeError as e:
98
+ print(f"Error: Invalid JSON schema file: {e}")
99
+ sys.exit(1)
100
+ except Exception as e:
101
+ print(f"Error: Validation error: {e}")
102
+ sys.exit(1)
103
+
104
+
105
+ def load_packages(config: dict, platform: Platform) -> dict[str, list[Package]]:
106
+ return {
107
+ name: pkgs
108
+ for name, pkg_list in config.items()
109
+ for pkgs in [load_package_list(name, pkg_list, platform)]
110
+ if len(pkgs) > 0
111
+ }
112
+
113
+
114
+ def load_package_list(name: str, config: list[dict], platform: Platform) -> list[Package]:
115
+ for item in config:
116
+ packages = Package.create(
117
+ name,
118
+ item if isinstance(item, dict) else {"type": item},
119
+ platform,
120
+ )
121
+
122
+ if len(packages) > 0:
123
+ return packages
124
+
125
+ return []
126
+
127
+
128
+ def resolve_packages(packages: dict[str, list[Package]]) -> list[Package]:
129
+ seen: set[Package] = set()
130
+ return [
131
+ resolved
132
+ for pkg_list in packages.values()
133
+ for pkg in pkg_list
134
+ for resolved in pkg.resolve(packages)
135
+ if resolved not in seen and seen.add(resolved) is None # type: ignore[func-returns-value]
136
+ ]
137
+
138
+
139
+ def merge_packages(packages: list[Package]) -> list[Package]:
140
+ merged: list[Package] = []
141
+
142
+ for pkg in packages:
143
+ for i, existing in enumerate(merged):
144
+ merged_pkg = existing.merge(pkg)
145
+ if merged_pkg is not None:
146
+ merged[i] = merged_pkg
147
+ break
148
+ else:
149
+ merged.append(pkg)
150
+
151
+ return merged
152
+
153
+
154
+ ## Platforms ###
155
+
156
+ @dataclass(frozen=True)
157
+ class Platform(ABC):
158
+ preinstalled_packages: tuple[str, ...] = field(default_factory=tuple)
159
+ blacklisted_types: tuple[str, ...] = field(default_factory=tuple)
160
+
161
+ def allows(self, package_type: str) -> bool:
162
+ return package_type not in self.blacklisted_types
163
+
164
+ def preinstalls(self, package_type: str) -> bool:
165
+ return package_type in self.preinstalled_packages
166
+
167
+
168
+ PLATFORMS = {
169
+ 'ubuntu': Platform(
170
+ preinstalled_packages=('bash', 'apt', 'deb', 'snapd'),
171
+ blacklisted_types=('dnf',),
172
+ ),
173
+ 'popos': Platform(
174
+ preinstalled_packages=('bash', 'apt', 'deb', 'flatpak'),
175
+ blacklisted_types=('dnf',),
176
+ ),
177
+ 'mint': Platform(
178
+ preinstalled_packages=('bash', 'apt', 'deb'),
179
+ blacklisted_types=('dnf',),
180
+ ),
181
+ 'fedora': Platform(
182
+ preinstalled_packages=('bash', 'dnf', 'flatpak'),
183
+ blacklisted_types=('apt', 'deb'),
184
+ ),
185
+ }
186
+
187
+
188
+ ### Package Implementations ###
189
+
190
+ T = TypeVar('T', bound='Package')
191
+
192
+ @dataclass(frozen=True)
193
+ class Package(ABC, Generic[T]):
194
+ factories: ClassVar[dict[str, type[Package]]] = {}
195
+
196
+ satisfies: tuple[str, ...] = field(default_factory=tuple)
197
+ pre_install: tuple[Command, ...] = field(default_factory=tuple)
198
+ post_install: tuple[Command, ...] = field(default_factory=tuple)
199
+ flags: tuple[str, ...] = field(default_factory=tuple)
200
+ dependencies: tuple[str, ...] = field(default_factory=tuple)
201
+
202
+ def __init_subclass__(cls, *, type: str | None = None, **kwargs):
203
+ super().__init_subclass__(**kwargs)
204
+ if type is not None:
205
+ cls.factories[type] = cls
206
+
207
+ @classmethod
208
+ def create(cls, name: str, item: dict, platform: Platform) -> list[Package]:
209
+ if 'type' not in item:
210
+ return []
211
+
212
+ if not platform.allows(item['type']):
213
+ return []
214
+
215
+ factory = cls.factories.get(item['type'], UndefinedPackage)
216
+ return factory.create(name, item, platform)
217
+
218
+
219
+ def print(self) -> str:
220
+ parts = []
221
+
222
+ for cmd in self.pre_install:
223
+ parts.append(cmd.print())
224
+
225
+ parts.append("".join(self.print_package()).strip())
226
+
227
+ for cmd in self.post_install:
228
+ parts.append(cmd.print())
229
+
230
+ return "\n\n".join(parts) + "\n"
231
+
232
+ @abstractmethod
233
+ def print_package(self) -> list[str]:
234
+ pass
235
+
236
+ def resolve(self, all_packages: dict[str, list[Package]]) -> list[Package]:
237
+ return [self]
238
+
239
+ def merge(self: T, other: Package) -> T | None:
240
+ if not isinstance(other, type(self)):
241
+ return None
242
+
243
+ if self.flags != other.flags:
244
+ return None
245
+
246
+ if not set(self.dependencies).isdisjoint(set(other.satisfies)):
247
+ return None
248
+
249
+ if not set(other.dependencies).isdisjoint(set(self.satisfies)):
250
+ return None
251
+
252
+ return self.apply_merge(other)
253
+
254
+ def apply_merge(self: T, other: T) -> T | None:
255
+ return None
256
+
257
+ def calculate_transitive_dependencies(self, packages: list[Package]) -> Package:
258
+ return replace(self, dependencies=tuple(sorted(self.all_dependencies(packages))))
259
+
260
+ def all_dependencies(self, packages: list[Package]) -> set[str]:
261
+ dependencies = set(self.dependencies)
262
+ dependencies.update(
263
+ transitive_dep
264
+ for dep in self.dependencies
265
+ for pkg in packages
266
+ if dep in pkg.satisfies
267
+ for transitive_dep in pkg.all_dependencies(packages)
268
+ )
269
+ return dependencies
270
+
271
+
272
+ @dataclass(frozen=True)
273
+ class DnfPackage(Package, type='dnf'):
274
+ packages: tuple[str, ...] = field(default_factory=tuple)
275
+ sudo: bool = field(default=True)
276
+
277
+ @classmethod
278
+ def create(cls, name: str, item: dict, platform: Platform) -> list[Package]:
279
+ packages = create_packages_list(item, name)
280
+ pre_install, post_install, deps = create_common_package_fields(name, item, platform)
281
+ flags = item.get('flags', [])
282
+ sudo = item.get('sudo', True)
283
+
284
+ if not platform.preinstalls('dnf'):
285
+ deps['dnf'] = [UndefinedPackage(name='dnf')]
286
+
287
+ if 'repofile' in item:
288
+ repo_file = item['repofile']
289
+ pre_install.append(ShellCommand(
290
+ command=f"sudo dnf config-manager addrepo --from-repofile={repo_file} --overwrite\n"
291
+ ))
292
+
293
+ if 'repo' in item:
294
+ flags.append(f"--repo {item['repo']}")
295
+
296
+ if 'copr' in item:
297
+ pre_install.append(ShellCommand(
298
+ command=f"sudo dnf copr enable {item['copr']} -y\n"
299
+ ))
300
+
301
+ return [
302
+ *(pkg for pkgs in deps.values() for pkg in pkgs),
303
+ DnfPackage(
304
+ satisfies=(name,),
305
+ packages=tuple(packages),
306
+ sudo=sudo,
307
+ pre_install=tuple(pre_install),
308
+ post_install=tuple(post_install),
309
+ flags=tuple(flags),
310
+ dependencies=tuple(deps.keys()),
311
+ )
312
+ ]
313
+
314
+ def print_package(self) -> list[str]:
315
+ parts = []
316
+ if self.sudo:
317
+ parts.append('sudo ')
318
+ parts.append('dnf install -y ')
319
+
320
+ for pkg in self.packages:
321
+ parts.append('"')
322
+ parts.append(pkg)
323
+ parts.append('" ')
324
+
325
+ for flag in self.flags:
326
+ parts.append(flag)
327
+ parts.append(" ")
328
+
329
+ return parts
330
+
331
+ def apply_merge(self, other: 'DnfPackage') -> 'DnfPackage' | None:
332
+ merged_packages = tuple(sorted(set(self.packages) | set(other.packages)))
333
+ return DnfPackage(
334
+ satisfies=self.satisfies + other.satisfies,
335
+ packages=merged_packages,
336
+ sudo=self.sudo,
337
+ pre_install=self.pre_install + other.pre_install,
338
+ post_install=self.post_install + other.post_install,
339
+ flags=self.flags,
340
+ dependencies=self.dependencies + other.dependencies,
341
+ )
342
+
343
+
344
+ @dataclass(frozen=True)
345
+ class AptPackage(Package, type='apt'):
346
+ packages: tuple[str, ...] = field(default_factory=tuple)
347
+ sudo: bool = field(default=True)
348
+
349
+ @classmethod
350
+ def create(cls, name: str, item: dict, platform: Platform) -> list[Package]:
351
+ packages = create_packages_list(item, name)
352
+ pre_install, post_install, deps = create_common_package_fields(name, item, platform)
353
+ flags = item.get('flags', [])
354
+ sudo = item.get('sudo', True)
355
+
356
+ if not platform.preinstalls('apt'):
357
+ deps['apt'] = [UndefinedPackage(name='apt')]
358
+
359
+ return [
360
+ *(pkg for pkgs in deps.values() for pkg in pkgs),
361
+ AptPackage(
362
+ satisfies=(name,),
363
+ packages=tuple(packages),
364
+ sudo=sudo,
365
+ pre_install=tuple(pre_install),
366
+ post_install=tuple(post_install),
367
+ flags=tuple(flags),
368
+ dependencies=tuple(deps.keys()),
369
+ )
370
+ ]
371
+
372
+ def print_package(self) -> list[str]:
373
+ parts = []
374
+ if self.sudo:
375
+ parts.append('sudo ')
376
+ parts.append('apt-get install -y ')
377
+
378
+ for pkg in self.packages:
379
+ parts.append('"')
380
+ parts.append(pkg)
381
+ parts.append('" ')
382
+
383
+ for flag in self.flags:
384
+ parts.append(flag)
385
+ parts.append(' ')
386
+
387
+ return parts
388
+
389
+ def apply_merge(self, other: 'AptPackage') -> 'AptPackage' | None:
390
+ merged_packages = tuple(sorted(set(self.packages) | set(other.packages)))
391
+ return AptPackage(
392
+ satisfies=self.satisfies + other.satisfies,
393
+ packages=merged_packages,
394
+ sudo=self.sudo,
395
+ pre_install=self.pre_install + other.pre_install,
396
+ post_install=self.post_install + other.post_install,
397
+ flags=self.flags,
398
+ dependencies=self.dependencies + other.dependencies,
399
+ )
400
+
401
+
402
+ @dataclass(frozen=True)
403
+ class DebPackage(Package, type='deb'):
404
+ packages: tuple[str, ...] = field(default_factory=tuple)
405
+ sudo: bool = field(default=True)
406
+
407
+ @classmethod
408
+ def create(cls, name: str, item: dict, platform: Platform) -> list[Package]:
409
+ packages = create_packages_list(item, name)
410
+ pre_install, post_install, deps = create_common_package_fields(name, item, platform)
411
+ flags = item.get('flags', [])
412
+ sudo = item.get('sudo', True)
413
+
414
+ if not platform.preinstalls('deb'):
415
+ deps['deb'] = [UndefinedPackage(name='deb')]
416
+
417
+ return [
418
+ *(pkg for pkgs in deps.values() for pkg in pkgs),
419
+ DebPackage(
420
+ satisfies=(name,),
421
+ packages=tuple(packages),
422
+ sudo=sudo,
423
+ pre_install=tuple(pre_install),
424
+ post_install=tuple(post_install),
425
+ flags=tuple(flags),
426
+ dependencies=tuple(deps.keys()),
427
+ )
428
+ ]
429
+
430
+ def print_package(self) -> list[str]:
431
+ parts = []
432
+
433
+ for pkg in self.packages:
434
+ parts.append('TMP_FILE=$(mktemp)\n')
435
+ parts.append('wget -O "$TMP_FILE" "')
436
+ parts.append(pkg)
437
+ parts.append('"\n')
438
+ if self.sudo:
439
+ parts.append('sudo ')
440
+ parts.append('apt-get install -y "$TMP_FILE" ')
441
+
442
+ for flag in self.flags:
443
+ parts.append(flag)
444
+ parts.append(" ")
445
+
446
+ parts.append("\n")
447
+ parts.append('rm "$TMP_FILE"\n')
448
+
449
+ return parts
450
+
451
+
452
+ @dataclass(frozen=True)
453
+ class SnapPackage(Package, type='snapd'):
454
+ packages: tuple[str, ...] = field(default_factory=tuple)
455
+ sudo: bool = field(default=True)
456
+
457
+ @classmethod
458
+ def create(cls, name: str, item: dict, platform: Platform) -> list[Package]:
459
+ packages = create_packages_list(item, name)
460
+ pre_install, post_install, deps = create_common_package_fields(name, item, platform)
461
+ flags = item.get('flags', [])
462
+ sudo = item.get('sudo', True)
463
+
464
+ if not platform.preinstalls('snapd'):
465
+ deps['snapd'] = [UndefinedPackage(name='snapd')]
466
+
467
+ if 'classic' in item and item['classic']:
468
+ flags.append('--classic')
469
+
470
+ return [
471
+ *(pkg for pkgs in deps.values() for pkg in pkgs),
472
+ SnapPackage(
473
+ satisfies=(name,),
474
+ packages=tuple(packages),
475
+ sudo=sudo,
476
+ pre_install=tuple(pre_install),
477
+ post_install=tuple(post_install),
478
+ flags=tuple(flags),
479
+ dependencies=tuple(deps.keys()),
480
+ )
481
+ ]
482
+
483
+ def print_package(self) -> list[str]:
484
+ parts = []
485
+ if self.sudo:
486
+ parts.append('sudo ')
487
+ parts.append('snap install ')
488
+
489
+ for pkg in self.packages:
490
+ parts.append('"')
491
+ parts.append(pkg)
492
+ parts.append('" ')
493
+
494
+ for flag in self.flags:
495
+ parts.append(flag)
496
+ parts.append(' ')
497
+
498
+ return parts
499
+
500
+ def apply_merge(self, other: 'SnapPackage') -> 'SnapPackage' | None:
501
+ if self.sudo != other.sudo:
502
+ return None
503
+
504
+ merged_packages = tuple(sorted(set(self.packages) | set(other.packages)))
505
+ return SnapPackage(
506
+ satisfies=self.satisfies + other.satisfies,
507
+ packages=merged_packages,
508
+ sudo=self.sudo,
509
+ pre_install=self.pre_install + other.pre_install,
510
+ post_install=self.post_install + other.post_install,
511
+ flags=self.flags,
512
+ dependencies=self.dependencies + other.dependencies,
513
+ )
514
+
515
+
516
+ @dataclass(frozen=True)
517
+ class FlatpakPackage(Package, type='flatpak'):
518
+ remote: str = field(default="flathub")
519
+ packages: tuple[str, ...] = field(default_factory=tuple)
520
+ sudo: bool = field(default=False)
521
+
522
+ @classmethod
523
+ def create(cls, name: str, item: dict, platform: Platform) -> list[Package]:
524
+ packages = create_packages_list(item, name)
525
+ pre_install, post_install, deps = create_common_package_fields(name, item, platform)
526
+ flags = item.get('flags', [])
527
+ sudo = item.get('sudo', False)
528
+
529
+ if not platform.preinstalls('flatpak'):
530
+ deps['flatpak'] = [UndefinedPackage(name='flatpak')]
531
+
532
+ remote = "flathub"
533
+ if 'remote' in item:
534
+ remote = item['remote']
535
+
536
+ return [
537
+ *(pkg for pkgs in deps.values() for pkg in pkgs),
538
+ FlatpakPackage(
539
+ satisfies=(name,),
540
+ packages=tuple(packages),
541
+ sudo=sudo,
542
+ pre_install=tuple(pre_install),
543
+ post_install=tuple(post_install),
544
+ flags=tuple(flags),
545
+ dependencies=tuple(deps.keys()),
546
+ remote=remote,
547
+ )
548
+ ]
549
+
550
+ def print_package(self) -> list[str]:
551
+ parts = []
552
+ if self.sudo:
553
+ parts.append('sudo ')
554
+ parts.append('flatpak install -y ')
555
+ parts.append(self.remote)
556
+ parts.append(' ')
557
+
558
+ for pkg in self.packages:
559
+ parts.append('"')
560
+ parts.append(pkg)
561
+ parts.append('" ')
562
+
563
+ for flag in self.flags:
564
+ parts.append(flag)
565
+ parts.append(" ")
566
+
567
+ return parts
568
+
569
+ def apply_merge(self, other: 'FlatpakPackage') -> 'FlatpakPackage' | None:
570
+ if self.sudo != other.sudo:
571
+ return None
572
+
573
+ merged_packages = tuple(sorted(set(self.packages) | set(other.packages)))
574
+ return FlatpakPackage(
575
+ satisfies=self.satisfies + other.satisfies,
576
+ packages=merged_packages,
577
+ sudo=self.sudo,
578
+ pre_install=self.pre_install + other.pre_install,
579
+ post_install=self.post_install + other.post_install,
580
+ flags=self.flags,
581
+ dependencies=self.dependencies + other.dependencies,
582
+ remote=self.remote,
583
+ )
584
+
585
+
586
+ @dataclass(frozen=True)
587
+ class PipPackage(Package, type='pip'):
588
+ packages: tuple[str, ...] = field(default_factory=tuple)
589
+ sudo: bool = field(default=False)
590
+
591
+ @classmethod
592
+ def create(cls, name: str, item: dict, platform: Platform) -> list[Package]:
593
+ packages = create_packages_list(item, name)
594
+ pre_install, post_install, deps = create_common_package_fields(name, item, platform)
595
+ flags = item.get('flags', [])
596
+ sudo = item.get('sudo', False)
597
+
598
+ if not platform.preinstalls('pip'):
599
+ deps['pip'] = [UndefinedPackage(name='pip')]
600
+
601
+ return [
602
+ *(pkg for pkgs in deps.values() for pkg in pkgs),
603
+ PipPackage(
604
+ satisfies=(name,),
605
+ packages=tuple(packages),
606
+ sudo=sudo,
607
+ pre_install=tuple(pre_install),
608
+ post_install=tuple(post_install),
609
+ flags=tuple(flags),
610
+ dependencies=tuple(deps.keys()),
611
+ )
612
+ ]
613
+
614
+ def print_package(self) -> list[str]:
615
+ parts = []
616
+ if self.sudo:
617
+ parts.append('sudo ')
618
+ parts.append("pip install -U ")
619
+ for pkg in self.packages:
620
+ parts.append('"')
621
+ parts.append(pkg)
622
+ parts.append('" ')
623
+
624
+ for flag in self.flags:
625
+ parts.append(flag)
626
+ parts.append(' ')
627
+
628
+ return parts
629
+
630
+ def apply_merge(self, other: 'PipPackage') -> 'PipPackage' | None:
631
+ if self.sudo != other.sudo:
632
+ return None
633
+
634
+ merged_packages = tuple(sorted(set(self.packages) | set(other.packages)))
635
+ return PipPackage(
636
+ satisfies=self.satisfies + other.satisfies,
637
+ packages=merged_packages,
638
+ sudo=self.sudo,
639
+ pre_install=self.pre_install + other.pre_install,
640
+ post_install=self.post_install + other.post_install,
641
+ flags=self.flags,
642
+ dependencies=self.dependencies + other.dependencies,
643
+ )
644
+
645
+
646
+ @dataclass(frozen=True)
647
+ class TarPackage(Package, type='tar'):
648
+ url: str = field(default="")
649
+ sudo: bool = field(default=False)
650
+ destination: str = field(default="")
651
+
652
+ @classmethod
653
+ def create(cls, name: str, item: dict, platform: Platform) -> list[Package]:
654
+ url = item.get('url')
655
+ destination = item.get('destination')
656
+ sudo = item.get('sudo', False)
657
+
658
+ if not url:
659
+ raise ValueError(f"TarPackage '{name}': 'url' field is required")
660
+ if not destination:
661
+ raise ValueError(f"TarPackage '{name}': 'destination' field is required")
662
+
663
+ pre_install, post_install, deps = create_common_package_fields(name, item, platform)
664
+
665
+ return [
666
+ *(pkg for pkgs in deps.values() for pkg in pkgs),
667
+ TarPackage(
668
+ satisfies=(name,),
669
+ url=url,
670
+ destination=destination,
671
+ sudo=sudo,
672
+ pre_install=tuple(pre_install),
673
+ post_install=tuple(post_install),
674
+ dependencies=tuple(deps.keys()),
675
+ )
676
+ ]
677
+
678
+ def print_package(self) -> list[str]:
679
+ parts = []
680
+ parts.append('curl -fsSL "')
681
+ parts.append(self.url)
682
+ parts.append('" | ')
683
+
684
+ if self.sudo:
685
+ parts.append('sudo ')
686
+
687
+ parts.append('tar xzvC "')
688
+ parts.append(self.destination)
689
+ parts.append('"')
690
+
691
+ return parts
692
+
693
+
694
+ @dataclass(frozen=True)
695
+ class ZipPackage(Package, type='zip'):
696
+ url: str = field(default="")
697
+ sudo: bool = field(default=False)
698
+ destination: str = field(default="")
699
+
700
+ @classmethod
701
+ def create(cls, name: str, item: dict, platform: Platform) -> list[Package]:
702
+ url = item.get('url')
703
+ destination = item.get('destination')
704
+ sudo = item.get('sudo', False)
705
+
706
+ if not url:
707
+ raise ValueError(f"ZipPackage '{name}': 'url' field is required")
708
+ if not destination:
709
+ raise ValueError(f"ZipPackage '{name}': 'destination' field is required")
710
+
711
+ pre_install, post_install, deps = create_common_package_fields(name, item, platform)
712
+
713
+ return [
714
+ *(pkg for pkgs in deps.values() for pkg in pkgs),
715
+ ZipPackage(
716
+ satisfies=(name,),
717
+ url=url,
718
+ destination=destination,
719
+ sudo=sudo,
720
+ pre_install=tuple(pre_install),
721
+ post_install=tuple(post_install),
722
+ dependencies=tuple(deps.keys()),
723
+ )
724
+ ]
725
+
726
+ def print_package(self) -> list[str]:
727
+ lines = []
728
+ lines.append('TMP_FILE=$(mktemp)\n')
729
+ lines.append('curl -fsSL "')
730
+ lines.append(self.url)
731
+ lines.append('" -o "$TMP_FILE"\n')
732
+ if self.sudo:
733
+ lines.append('sudo ')
734
+ lines.append('unzip "$TMP_FILE" -d "')
735
+ lines.append(self.destination)
736
+ lines.append('"\n')
737
+ lines.append('rm "$TMP_FILE"\n')
738
+
739
+ return lines
740
+
741
+
742
+ @dataclass(frozen=True)
743
+ class GitHubPackage(Package, type='github'):
744
+ repository: str = field(default="")
745
+ install: str = field(default="")
746
+
747
+ @classmethod
748
+ def create(cls, name: str, item: dict, platform: Platform) -> list[Package]:
749
+ repository = item.get('repository')
750
+ install = item.get('install', "")
751
+
752
+ if not repository:
753
+ raise ValueError(f"GitHubPackage '{name}': 'repository' field is required")
754
+ if not install:
755
+ raise ValueError(f"GitHubPackage '{name}': 'install' field is required")
756
+
757
+ pre_install, post_install, deps = create_common_package_fields(name, item, platform)
758
+
759
+ return [
760
+ *(pkg for pkgs in deps.values() for pkg in pkgs),
761
+ GitHubPackage(
762
+ satisfies=(name,),
763
+ repository=repository,
764
+ install=install,
765
+ pre_install=tuple(pre_install),
766
+ post_install=tuple(post_install),
767
+ dependencies=tuple(deps.keys()),
768
+ )
769
+ ]
770
+
771
+ def print_package(self) -> list[str]:
772
+ lines = []
773
+ lines.append('TMP_DIR=$(mktemp -d)\n')
774
+ lines.append('git clone https://github.com/')
775
+ lines.append(self.repository)
776
+ lines.append('.git "$TMP_DIR"\n')
777
+ lines.append('(\n')
778
+ lines.append(' cd "$TMP_DIR"\n')
779
+
780
+ for line in self.install.splitlines():
781
+ lines.append(' ')
782
+ lines.append(line)
783
+ lines.append('\n')
784
+
785
+ lines.append(')\n')
786
+ lines.append('rm -rf "$TMP_DIR"\n')
787
+
788
+ return lines
789
+
790
+
791
+ @dataclass(frozen=True)
792
+ class FilePackage(Package, type='file'):
793
+ url: str = field(default="")
794
+ destination: str = field(default="")
795
+ sudo: bool = field(default=False)
796
+ silent: bool = field(default=False)
797
+ executable: bool = field(default=False)
798
+
799
+ @classmethod
800
+ def create(cls, name: str, item: dict, platform: Platform) -> list[Package]:
801
+ url = item.get('url')
802
+ destination = item.get('destination')
803
+ sudo = item.get('sudo', False)
804
+ silent = item.get('silent', False)
805
+ executable = item.get('executable', False)
806
+
807
+ if not url or not destination:
808
+ raise RuntimeError(f"FilePackage requires 'url' and 'destination' fields.")
809
+
810
+ pre_install, post_install, deps = create_common_package_fields(name, item, platform)
811
+
812
+ return [
813
+ *(pkg for pkgs in deps.values() for pkg in pkgs),
814
+ FilePackage(
815
+ satisfies=(name,),
816
+ url=url,
817
+ destination=destination,
818
+ sudo=sudo,
819
+ silent=silent,
820
+ executable=executable,
821
+ pre_install=tuple(pre_install),
822
+ post_install=tuple(post_install),
823
+ dependencies=tuple(deps.keys()),
824
+ )
825
+ ]
826
+
827
+ def print_package(self) -> list[str]:
828
+ parts = []
829
+ parts.append('curl -fsSL "')
830
+ parts.append(self.url)
831
+ parts.append('" | ')
832
+
833
+ if self.sudo:
834
+ parts.append("sudo ")
835
+
836
+ parts.append('tee "')
837
+ parts.append(self.destination)
838
+ parts.append('"')
839
+
840
+ if self.silent:
841
+ parts.append(' > /dev/null')
842
+
843
+ if self.executable:
844
+ parts.append('\n')
845
+
846
+ if self.sudo:
847
+ parts.append('sudo ')
848
+
849
+ parts.append('chmod +x "')
850
+ parts.append(self.destination)
851
+ parts.append('"')
852
+
853
+ return parts
854
+
855
+
856
+ @dataclass(frozen=True)
857
+ class AppImagePackage(Package, type='appimage'):
858
+ url: str = field(default="")
859
+ name: str = field(default="")
860
+ icon_name: str = field(default="")
861
+
862
+ @classmethod
863
+ def create(cls, name: str, item: dict, platform: Platform) -> list[Package]:
864
+ url = item.get('url')
865
+ icon_name = item.get('icon_name')
866
+
867
+ if not url:
868
+ raise RuntimeError(f"AppImagePackage requires 'url' field.")
869
+
870
+ # Add dependency on appimage setup
871
+ pre_install, post_install, deps = create_common_package_fields(name, item, platform)
872
+
873
+ if not platform.preinstalls('appimage'):
874
+ deps['appimage'] = [UndefinedPackage(name='appimage')]
875
+
876
+ # Set up post_install commands for desktop integration
877
+ app_name = item.get('name', name)
878
+ destination = f"$HOME/.local/bin/{app_name}.AppImage"
879
+
880
+ # Desktop file creation
881
+ desktop_entry = []
882
+ desktop_entry.append('[Desktop Entry]')
883
+ desktop_entry.append(f'Name={app_name}')
884
+ desktop_entry.append('StartupNotify=true')
885
+ desktop_entry.append('Type=Application')
886
+ desktop_entry.append('Terminal=false')
887
+ desktop_entry.append(f'Categories={";".join(item.get('categories', 'Application'))};')
888
+ if icon_name:
889
+ desktop_entry.append(f'Icon={icon_name}')
890
+ desktop_entry.append(f'Exec=sh -c "{destination}"')
891
+ desktop_entry.append('')
892
+
893
+ post_install.append(
894
+ TeeCommand(
895
+ '\n'.join(desktop_entry),
896
+ destination=f"$HOME/.local/share/applications/{app_name}.desktop",
897
+ mkdir=True,
898
+ )
899
+ )
900
+
901
+ # Icon extraction if icon_name is provided
902
+ if icon_name:
903
+ extract_icons = []
904
+ extract_icons.append('(')
905
+ extract_icons.append(' TMP_DIR=$(mktemp -d)')
906
+ extract_icons.append(' cd "$TMP_DIR" || exit')
907
+ extract_icons.append(f' "{destination}" --appimage-extract')
908
+ extract_icons.append(' cp -rv squashfs-root/usr/share/icons/hicolor/* "$HOME/.local/share/icons/hicolor/" 2>/dev/null || true')
909
+ extract_icons.append(' rm -rf "$TMP_DIR"')
910
+ extract_icons.append(')')
911
+
912
+ post_install.append(
913
+ ShellCommand(command='\n'.join(extract_icons))
914
+ )
915
+
916
+
917
+ return [
918
+ *(pkg for pkgs in deps.values() for pkg in pkgs),
919
+ AppImagePackage(
920
+ satisfies=(name,),
921
+ url=url,
922
+ name=app_name,
923
+ pre_install=tuple(pre_install),
924
+ post_install=tuple(post_install),
925
+ dependencies=tuple(deps.keys()),
926
+ )
927
+ ]
928
+
929
+ def print_package(self) -> list[str]:
930
+ parts = []
931
+ destination = f"$HOME/.local/bin/{self.name}.AppImage"
932
+
933
+ parts.append('mkdir -p "$(dirname "')
934
+ parts.append(destination)
935
+ parts.append('")"\n')
936
+
937
+ # Download AppImage
938
+ parts.append('curl -fsSL "')
939
+ parts.append(self.url)
940
+ parts.append('" -o "')
941
+ parts.append(destination)
942
+ parts.append('" > /dev/null\n')
943
+
944
+ # Make executable
945
+ parts.append('chmod +x "')
946
+ parts.append(destination)
947
+ parts.append('"\n')
948
+
949
+ return parts
950
+
951
+
952
+ @dataclass(frozen=True)
953
+ class ShellPackage(Package, type='shell'):
954
+ shell: str = field(default="bash")
955
+ script: str | None = field(default=None)
956
+ url: str | None = field(default=None)
957
+ sudo: bool = field(default=False)
958
+
959
+ @classmethod
960
+ def create(cls, name: str, item: dict, platform: Platform) -> list[Package]:
961
+ shell = item.get('shell', 'bash')
962
+ script = item.get('script')
963
+ url = item.get('url')
964
+ sudo = item.get('sudo', False)
965
+
966
+ if not script and not url:
967
+ raise RuntimeError(f"ShellPackage requires either 'script' or 'url' field.")
968
+
969
+ pre_install, post_install, deps = create_common_package_fields(name, item, platform)
970
+
971
+ if not platform.preinstalls(shell):
972
+ deps[shell] = [UndefinedPackage(name=shell)]
973
+
974
+ return [
975
+ *(pkg for pkgs in deps.values() for pkg in pkgs),
976
+ ShellPackage(
977
+ satisfies=(name,),
978
+ shell=shell,
979
+ script=script,
980
+ url=url,
981
+ sudo=sudo,
982
+ pre_install=tuple(pre_install),
983
+ post_install=tuple(post_install),
984
+ dependencies=tuple(deps.keys()),
985
+ )
986
+ ]
987
+
988
+ def print_package(self) -> list[str]:
989
+ parts = []
990
+
991
+ if self.url:
992
+ parts.append(self.shell)
993
+ parts.append(' -c "$(curl -fsSL "')
994
+ parts.append(self.url)
995
+ parts.append('")"')
996
+ elif self.script:
997
+ parts.append(self.shell)
998
+ parts.append(' -i -c "\n')
999
+ parts.append(script_escape(self.script))
1000
+ parts.append('" </dev/null')
1001
+ else:
1002
+ raise RuntimeError("ShellPackage requires either 'script' or 'url' field.")
1003
+
1004
+ return parts
1005
+
1006
+
1007
+ @dataclass(frozen=True)
1008
+ class UndefinedPackage(Package):
1009
+ name: str = field(default="undefined")
1010
+
1011
+ @classmethod
1012
+ def create(cls, name: str, item: dict, platform: Platform) -> list[Package]:
1013
+ return [UndefinedPackage(name=name)]
1014
+
1015
+ def print_package(self) -> list[str]:
1016
+ return [f"# TODO: Add installation command for package: {self.name}"]
1017
+
1018
+ def resolve(self, all_packages: dict[str, list[Package]]) -> list[Package]:
1019
+ return [
1020
+ resolved
1021
+ for pkg in all_packages.get(self.name, [])
1022
+ if pkg != self
1023
+ for resolved in pkg.resolve(all_packages)
1024
+ ] if self.name in all_packages else super().resolve(all_packages)
1025
+
1026
+
1027
+ ## Package Helpers ###
1028
+
1029
+
1030
+ def create_packages_list(item: dict, default: str) -> list[str]:
1031
+ if 'packages' in item:
1032
+ return item['packages']
1033
+ else:
1034
+ return [default]
1035
+
1036
+
1037
+ def create_common_package_fields(name: str, item: dict, platform: Platform) -> tuple[list[Command], list[Command], dict[str, list[Package]]]:
1038
+ pre_install = create_install_commands(item.get('pre_install', []))
1039
+ post_install = create_install_commands(item.get('post_install', []))
1040
+ deps = load_dependencies(name, item.get('depends_on', []), platform)
1041
+ return pre_install, post_install, deps
1042
+
1043
+
1044
+ def create_install_commands(commands: list | dict | str) -> list[Command]:
1045
+ return [
1046
+ cmd
1047
+ for command in (commands if isinstance(commands, list) else [commands])
1048
+ for cmd in [Command.create(
1049
+ command if isinstance(command, dict)
1050
+ else {'type': 'shell', 'command': command}
1051
+ )]
1052
+ if cmd is not None
1053
+ ]
1054
+
1055
+
1056
+ def load_dependencies(name: str, config: list[dict], platform: Platform) -> dict[str, list[Package]]:
1057
+ deps: dict[str, list[Package]] = {}
1058
+
1059
+ for i, item in enumerate(config):
1060
+ if isinstance(item, str):
1061
+ deps[item] = [UndefinedPackage(name=item)]
1062
+ continue
1063
+
1064
+ deps[f"__{name}_{i}"] = Package.create(f"__{name}_{i}", item, platform)
1065
+
1066
+ return deps
1067
+
1068
+
1069
+ def script_escape(script: str) -> str:
1070
+ return script.replace('\\', '\\\\').replace('"', '\\"').replace('$', '\\$').replace('`', '\\`')
1071
+
1072
+
1073
+ ## Command Implementations ###
1074
+
1075
+ @dataclass(frozen=True)
1076
+ class Command:
1077
+ factories: ClassVar[dict[str, type[Command]]] = {}
1078
+
1079
+ def __init_subclass__(cls, *, type: str | None = None, **kwargs):
1080
+ super().__init_subclass__(**kwargs)
1081
+ if type is not None:
1082
+ cls.factories[type] = cls
1083
+
1084
+ @classmethod
1085
+ def create(cls, item: dict) -> Command | None:
1086
+ factory = cls.factories.get(item.get('type', ''))
1087
+ return factory.create(item) if factory else None
1088
+
1089
+ @abstractmethod
1090
+ def print(self) -> str:
1091
+ pass
1092
+
1093
+
1094
+ @dataclass(frozen=True)
1095
+ class ShellCommand(Command, type='shell'):
1096
+ command: str = field(default="")
1097
+
1098
+ @classmethod
1099
+ def create(cls, item: dict) -> Command:
1100
+ return ShellCommand(command=item['command'])
1101
+
1102
+ def print(self) -> str:
1103
+ return self.command.strip()
1104
+
1105
+
1106
+ @dataclass(frozen=True)
1107
+ class TeeCommand(Command, type='tee'):
1108
+ content: str = field(default="")
1109
+ destination: str = field(default="")
1110
+ sudo: bool = field(default=False)
1111
+ append: bool = field(default=False)
1112
+ mkdir: bool = field(default=False)
1113
+
1114
+ @classmethod
1115
+ def create(cls, item: dict) -> Command:
1116
+ return TeeCommand(
1117
+ content=item['content'],
1118
+ destination=item['destination'],
1119
+ sudo=item.get('sudo', False),
1120
+ append=item.get('append', False),
1121
+ mkdir=item.get('mkdir', False)
1122
+ )
1123
+
1124
+ def print(self) -> str:
1125
+ parts = []
1126
+
1127
+ if self.mkdir:
1128
+ parts.append('mkdir -p "$(dirname "')
1129
+ parts.append(self.destination)
1130
+ parts.append('")"\n')
1131
+
1132
+ if self.sudo:
1133
+ parts.append('sudo ')
1134
+
1135
+ parts.append('tee ')
1136
+
1137
+ if self.append:
1138
+ parts.append('-a ')
1139
+
1140
+ parts.append('"')
1141
+ parts.append(self.destination)
1142
+ parts.append('"')
1143
+ parts.append(" <<'EOF'\n")
1144
+ parts.append(self.content)
1145
+ parts.append("EOF")
1146
+
1147
+ return "".join(parts).strip()
1148
+
1149
+
1150
+ ## Entry Point ###
1151
+
1152
+
1153
+ def _cli() -> None:
1154
+ args_parser = argparse.ArgumentParser(description="Generate installation scripts from YAML config.")
1155
+ args_parser.add_argument("config_path", help="Path to the YAML configuration file.")
1156
+ args_parser.add_argument("--os", required=True, help="Target operating system (e.g., 'ubuntu', 'fedora').")
1157
+ args_parser.add_argument("--out", help="Output shell script file path (optional, defaults to stdout).")
1158
+ args = args_parser.parse_args(sys.argv[1:])
1159
+ main(args)
1160
+
1161
+
1162
+ if __name__ == "__main__":
1163
+ _cli()