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-0.1.0.dist-info/METADATA +544 -0
- distroscript-0.1.0.dist-info/RECORD +7 -0
- distroscript-0.1.0.dist-info/WHEEL +4 -0
- distroscript-0.1.0.dist-info/entry_points.txt +2 -0
- distroscript-0.1.0.dist-info/licenses/LICENSE +28 -0
- distroscript.py +1163 -0
- schema.json +560 -0
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()
|