crosspack 0.5.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 446cc8ee80c8c72227611451283314f75a8c9a276e915cecd416c72269e10d7a
4
- data.tar.gz: f6186516867c2dbb74871991d33b9eb3e24ab79c627ad32dd169bf8b0be78611
3
+ metadata.gz: 8d03bbc2ce62c3f8cc511df1f4e9ade332adc838a361e2730154c4d9b94403a3
4
+ data.tar.gz: 8b3785216051ef0e2c926e9feb4a61d8473286fb3ba1a6b91d4ac19cf0969e88
5
5
  SHA512:
6
- metadata.gz: 1208559fef0f7a26b62b375d4ca90578fd52668cb30862f4ba9245e3402f6b4f0e512ef4b0029eea5072328c2818e21f9d3f5ebd584edfd286b34abc9a977fa3
7
- data.tar.gz: 6902bf1a81aa6ecaecc8fead2fed2886e80f948957e8bead59be6cba6ec9710d22457bed4e7ffb9e8b793e956baf764aab0a0c7ff584eb7ed13c81f23f4e3add
6
+ metadata.gz: 5372d580eb8e5730c13f1fffcd08c626e272ae436dc8f0d53a9a504e398f750d25948e29e8741c138044b3a2981f2082aa73e1ca418eaa0f399988ce56cf3d94
7
+ data.tar.gz: 63de5f8eafe2856831605c1e667c3d2ed32a65b29c405eb8ebf854c7792616e3fa2b7370ba963cd8d78e7aa9a02edec9ed14458a61e9d4f204772856abfba6f9
data/CHANGELOG.md CHANGED
@@ -5,6 +5,71 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.7.0] - 2026-09-13
9
+
10
+ ### Added
11
+ - Host-only commands: `crosspack run` builds for the current host (the same
12
+ stage `crosspack build <host target>` runs) and then launches the main
13
+ binary straight from `builds/` — the first `package.executables` entry,
14
+ not the package. Arguments after `--` are passed to the app
15
+ (`crosspack run -- --dev`); the app's exit code is propagated.
16
+ - `crosspack install` launches this host's package with its system
17
+ installer: the newest package in `crosspacks/<host target>/` goes to
18
+ `xdg-open` (deb/rpm), `msiexec /i` (MSI) or `open` (macOS dmg/.app);
19
+ for arch crosspack generates only a PKGBUILD, so it points at
20
+ `makepkg -si`.
21
+ - crosspack.yml `run:` / `install:` sections: per-host launch-command
22
+ overrides using the same host selectors as `build.deps` (distro id,
23
+ ID_LIKE, os, `"*"`), with the `{{path}}` placeholder (the built binary
24
+ for run, the package file for install) plus the usual build facts.
25
+ Default without a `run:` section: execute the binary directly (a macOS
26
+ `.app` bundle is `open`ed).
27
+
28
+ [0.7.0]: https://github.com/OrelSokolov/crosspack/releases/tag/v0.7.0
29
+
30
+ ## [0.6.2] - 2026-09-13
31
+
32
+ ### Added
33
+ - Colored CLI output via the new `colorize` runtime dependency: success
34
+ messages green, errors red, validation warnings yellow (the `ok`/`MISSING`
35
+ statuses in the deps report included). Colors apply only to a TTY and
36
+ respect `NO_COLOR`, so piped and logged output stays plain.
37
+ - The gem now ships `logo.png` in its files.
38
+
39
+ ## [0.6.1] - 2026-09-13
40
+
41
+ ### Fixed
42
+ - `crosspack deps` no longer dumps a Ruby backtrace when the (detected or
43
+ given) target has no matrix entry — it aborts with the plain message, now
44
+ with an actionable hint (pass a declared target or extend artifacts.to).
45
+ - `crosspack --version` (bare, also `-v`) prints the gem version instead of
46
+ demanding an argument; `--version X` keeps overriding the build/pack
47
+ version.
48
+
49
+ [0.6.2]: https://github.com/OrelSokolov/crosspack/releases/tag/v0.6.2
50
+ [0.6.1]: https://github.com/OrelSokolov/crosspack/releases/tag/v0.6.1
51
+
52
+ ## [0.6.0] - 2026-09-13
53
+
54
+ ### Added
55
+ - Host target default: `crosspack deps` / `build` / `pack` invoked without a
56
+ target run for the current host — `macos`, `windows-11.0` or the distro
57
+ family + version from `/etc/os-release` (unknown distro ids fall back to
58
+ the first known `ID_LIKE` token; unrecognizable hosts ask for an explicit
59
+ target).
60
+ - Per-target payload names: `package.payload` entries may now be written as a
61
+ mapping `common key -> {target selector -> real file name}` instead of a
62
+ flat list. The key is the common name (used verbatim on every target
63
+ unless overridden); selectors are an exact target (`windows-11.0`), a
64
+ family (`windows`) or `"*"` (exact target wins, then family, then `"*"`).
65
+ An entry with a selector map applies only to targets it covers, so one
66
+ payload lists `app`/`app.exe`, `.so` libraries and Windows DLLs together —
67
+ each target resolves to the files its build actually produced. The flat
68
+ list form remains valid (same name everywhere). `executables` keeps naming
69
+ common keys and resolves per target at pack time.
70
+
71
+ [0.6.0]: https://github.com/OrelSokolov/crosspack/releases/tag/v0.6.0
72
+
8
73
  ## [0.5.1] - 2026-09-13
9
74
 
10
75
  ### Fixed
data/README.md CHANGED
@@ -22,14 +22,24 @@ crosspack build ubuntu-24.04
22
22
  crosspack pack ubuntu-24.04
23
23
  ```
24
24
 
25
- ## crosspack.yml — one file, three sections
25
+ The target may be omitted — the stage commands then run for the current
26
+ host: `macos`, `windows-11.0` or the distro family + version from
27
+ `/etc/os-release` (unknown ids fall back to the first known `ID_LIKE`
28
+ token):
29
+
30
+ ```
31
+ crosspack deps && crosspack build && crosspack pack
32
+ ```
33
+
34
+ ## crosspack.yml — one file, three sections + two host hooks
26
35
 
27
36
  The top level carries the shared facts — `name` (required once) and the
28
37
  optional `version` scheme — and three sections: `build:` (what and where to
29
38
  build), `package:` (what and how to pack) and `deps:` (runtime dependencies
30
- resolved to concrete packages per distro). The shared name/version feed the
31
- build section, the name feeds the package section; each section keeps its
32
- own schema.
39
+ resolved to concrete packages per distro). Two optional hooks — `run:` and
40
+ `install:` — override the host-only launch commands. The shared name/version
41
+ feed the build section, the name feeds the package section; each section
42
+ keeps its own schema.
33
43
 
34
44
  ```yaml
35
45
  name: myapp
@@ -91,11 +101,27 @@ package:
91
101
  lib_dir: lib/myapp # optional, default lib/<name>
92
102
 
93
103
  payload: # files from sources -> <prefix>/<lib_dir>/
94
- - myapp
104
+ - myapp # flat form: the same name on every target
95
105
  - helper
96
106
  - model.onnx
97
107
 
98
- executables: [myapp, helper] # chmod 755, must be in payload
108
+ # Mapping form: per-target file names. The key is the common name (and the
109
+ # default file name); selectors are an exact target (windows-11.0), a
110
+ # family (windows), an os (linux — every distro family) or "*" — exact
111
+ # target wins, then family, then os, then "*". An entry with selectors
112
+ # applies only to targets it covers, so .so libraries and Windows DLLs can
113
+ # share one payload:
114
+ #
115
+ # payload:
116
+ # myapp:
117
+ # "*": myapp
118
+ # windows: myapp.exe
119
+ # libonnxruntime.so.1:
120
+ # linux: libonnxruntime.so.1
121
+ # onnxruntime.dll:
122
+ # windows: onnxruntime.dll
123
+
124
+ executables: [myapp, helper] # common keys; chmod 755, resolved per target
99
125
 
100
126
  links: # symlinks relative to prefix
101
127
  bin/myapp: ../lib/myapp/myapp
@@ -117,6 +143,15 @@ deps:
117
143
  fedora: { "*": [webkit2gtk4.1] }
118
144
  macos: system # OS component
119
145
  windows: system # preinstalled (WebView2)
146
+
147
+ # host-only launch hooks (optional): same host selectors as build.deps
148
+ run: # `crosspack run`: how to launch the built binary
149
+ hosts:
150
+ darwin: open build/bin/MyApp.app # .app bundles must be opened, not exec'd
151
+
152
+ install: # `crosspack install`: how to launch this host's package
153
+ hosts:
154
+ ubuntu: sudo apt-get install -y "{{path}}"
120
155
  ```
121
156
 
122
157
  Placeholders `{{version}}`, `{{name}}`, `{{platform}}`, `{{os}}`, `{{arch}}`
@@ -167,6 +202,10 @@ crosspack deps <target> [--check] # verify/install build deps (--check: r
167
202
  crosspack build <target>|--all [--no-deps] [--version X]
168
203
  crosspack pack <target> [--version X] # version defaults to the build stamp
169
204
 
205
+ # host-only: this machine, no target argument
206
+ crosspack run [-- --dev] # build for this host, then launch the main binary
207
+ crosspack install # launch this host's package with its installer
208
+
170
209
  # inspection
171
210
  crosspack validate # crosspack.yml: top level + all sections
172
211
  crosspack resolve <target> # Depends line for the target
@@ -187,6 +226,32 @@ crosspacks/fedora/41/x86_64/myapp-2026.08.31-1234.x86_64.rpm (needs rpmbuild)
187
226
  crosspacks/arch/x86_64/PKGBUILD
188
227
  ```
189
228
 
229
+ ### Host-only commands: run and install
230
+
231
+ `run` and `install` apply to the current host only — the build target *is*
232
+ the run target, so they take no target argument. `crosspack run` runs the
233
+ build stage for this host (same as `crosspack build <host target>`), then
234
+ launches the first `package.executables` binary straight from `builds/` —
235
+ the binary, not the package. Everything after `--` goes to the app:
236
+
237
+ ```
238
+ crosspack run -- --dev
239
+ ```
240
+
241
+ `crosspack install` takes the newest package packed for this host in
242
+ `crosspacks/` and hands it to the system installer. Defaults: `xdg-open`
243
+ for deb/rpm (opens GNOME Software/installer), `msiexec /i` for the MSI,
244
+ `open` for a macOS dmg/.app; for arch crosspack only generates a PKGBUILD,
245
+ so it points you at `makepkg -si` instead.
246
+
247
+ Both are per-system by nature, so each accepts a `hosts:` override with the
248
+ same selectors as `build.deps` (distro id, `ID_LIKE`, os, `"*"` — first
249
+ match wins). Commands support the `{{path}}` placeholder (the binary in
250
+ `builds/` for run, the package file for install) plus the usual
251
+ `{{name}}`/`{{version}}`/`{{os}}`/`{{arch}}` facts; quote values that start
252
+ with `{{` (YAML flow indicators). Without a `run:` section the binary is
253
+ executed directly, except a macOS `.app` bundle which is `open`ed.
254
+
190
255
  ## Library
191
256
 
192
257
  ```ruby
data/exe/crosspack CHANGED
@@ -12,25 +12,50 @@ rescue LoadError
12
12
  end
13
13
 
14
14
  def abort_with(message)
15
- warn message
15
+ warn Crosspack::Colors.red(message)
16
16
  exit 1
17
17
  end
18
18
 
19
+ # Validate reports line by line: valid → green, invalid → red, warnings → yellow.
20
+ def colorized_report(text)
21
+ text.lines.map do |line|
22
+ chomped = line.chomp
23
+ colored =
24
+ if chomped.include?('is invalid')
25
+ Crosspack::Colors.red(chomped)
26
+ elsif chomped.include?('is valid')
27
+ Crosspack::Colors.green(chomped)
28
+ elsif chomped.strip.start_with?('⚠')
29
+ Crosspack::Colors.yellow(chomped)
30
+ end
31
+ colored ? "#{colored}\n" : line
32
+ end.join
33
+ end
34
+
19
35
  options = { file: Crosspack::Config::DEFAULT_PATH, arch: 'amd64',
20
36
  format: nil, version: nil, root: Dir.pwd, output_base: nil,
21
37
  check_only: false, no_deps: false, all: false }
38
+
39
+ # `crosspack --version` (also bare -v) prints the gem version like any other
40
+ # CLI; with a value the flag stays the build/pack version override.
41
+ if ARGV.size == 1 && %w[--version -v].include?(ARGV.first)
42
+ puts "crosspack #{Crosspack::VERSION}"
43
+ exit
44
+ end
22
45
  parser = OptionParser.new do |opts|
23
46
  opts.banner = "crosspack #{Crosspack::VERSION} — staged pipeline: deps → build → pack\n" \
24
47
  "Usage: crosspack <command> [options]\n" \
25
- "Stages: deps <target> [--check] verify/install build deps for the target's host\n" \
26
- " build <target>|--all compile the target's matrix entry into builds/\n" \
27
- " pack <target> [--version X] package builds/<target> (version from the build stamp)\n" \
48
+ "Stages: deps [<target>] [--check] verify/install build deps (default: this host)\n" \
49
+ " build [<target>]|--all compile into builds/ (default: this host)\n" \
50
+ " pack [<target>] [--version X] package builds/<target> (default: this host; version from the build stamp)\n" \
51
+ "Host: run [-- <app args>] build for this host, then launch the main binary\n" \
52
+ " install launch this host's package with its installer\n" \
28
53
  'Commands: validate, resolve, matrix, version, targets'
29
54
 
30
55
  opts.on('-f', '--file PATH', 'path to crosspack.yml (default: ./crosspack.yml)') { |v| options[:file] = v }
31
56
  opts.on('--arch ARCH', 'architecture: amd64/x86_64, arm64/aarch64 (default: amd64)') { |v| options[:arch] = v }
32
57
  opts.on('--format FORMAT', 'output format for resolve: deb, rpm, pkgbuild (default: derived from the target)') { |v| options[:format] = v.to_sym }
33
- opts.on('--version VERSION', 'override the version for build/pack') { |v| options[:version] = v }
58
+ opts.on('--version VERSION', 'override the version for build/pack; a bare `crosspack --version` prints the gem version') { |v| options[:version] = v }
34
59
  opts.on('--root PATH', 'project root for relative paths (default: .)') { |v| options[:root] = v }
35
60
  opts.on('--output-base DIR', 'output directory (build stage: the manifest output, default builds; pack: default ./crosspacks)') { |v| options[:output_base] = v }
36
61
  opts.on('--check', 'with `deps`: only report presence, never install (exit 1 if missing)') { options[:check_only] = true }
@@ -79,6 +104,19 @@ def entry_for_target!(manifest, target, host_os, host_arch)
79
104
  end
80
105
 
81
106
  entry
107
+ rescue Crossbuild::Error => e
108
+ abort_with e.message
109
+ end
110
+
111
+ # Stage commands (deps/build/pack) run for the current host when no target
112
+ # is given: macos, windows-11.0 or the distro from /etc/os-release.
113
+ def host_target!(command)
114
+ raw = Crosspack::Target.host_string
115
+ abort_with "Could not detect this host's target — pass one explicitly: crosspack #{command} <target> " \
116
+ '(families: ubuntu-24.04, debian-12, fedora-41, arch, macos, windows-11.0)' if raw.nil?
117
+
118
+ puts "→ no target given; using this host's target: #{raw}"
119
+ raw
82
120
  end
83
121
 
84
122
  case command
@@ -86,11 +124,15 @@ when 'validate'
86
124
  deps_manifest = config.deps_manifest
87
125
  pkg = config.package_manifest
88
126
  build_manifest = config.build_manifest
89
- puts config.error_report
90
- puts deps_manifest.error_report
91
- puts pkg.error_report
92
- puts build_manifest.error_report
93
- ok = config.valid? && deps_manifest.valid? && pkg.valid? && build_manifest.valid?
127
+ puts colorized_report(config.error_report)
128
+ puts colorized_report(deps_manifest.error_report)
129
+ puts colorized_report(pkg.error_report)
130
+ puts colorized_report(build_manifest.error_report)
131
+ [config.run_manifest, config.install_manifest].each do |m|
132
+ puts colorized_report(m.error_report) if m.present?
133
+ end
134
+ ok = config.valid? && deps_manifest.valid? && pkg.valid? && build_manifest.valid? &&
135
+ config.run_manifest.valid? && config.install_manifest.valid?
94
136
  exit(ok ? 0 : 1)
95
137
  when 'matrix'
96
138
  deps_manifest = config.deps_manifest
@@ -131,12 +173,12 @@ when 'version'
131
173
  abort_with e.message
132
174
  end
133
175
  when 'deps'
134
- abort_with 'Specify a target: crosspack deps debian-12 (see crosspack --help)' unless positional
176
+ target_str = positional || host_target!('deps')
135
177
  build_manifest = config.build_manifest
136
178
  abort_with 'the build: section is invalid — run: crosspack validate' unless build_manifest.valid?
137
- entry_for_target!(build_manifest, positional, Crossbuild::Platform.os, Crossbuild::Platform.arch)
138
- installer = Crossbuild::DepInstaller.new(build_manifest, root: options[:root])
139
179
  begin
180
+ entry_for_target!(build_manifest, target_str, Crossbuild::Platform.os, Crossbuild::Platform.arch)
181
+ installer = Crossbuild::DepInstaller.new(build_manifest, root: options[:root])
140
182
  if options[:check_only]
141
183
  installer.check_all
142
184
  puts "deps on this host:\n#{installer.report}"
@@ -146,15 +188,14 @@ when 'deps'
146
188
  rescue Crossbuild::Error => e
147
189
  abort_with e.message
148
190
  end
149
- puts "deps ready:\n#{installer.report}"
191
+ puts Crosspack::Colors.green("deps ready:\n#{installer.report}")
150
192
  when 'build'
151
193
  build_manifest = config.build_manifest
152
194
  abort_with 'the build: section is invalid — run: crosspack validate' unless build_manifest.valid?
153
195
  if options[:all]
154
196
  target = nil
155
197
  else
156
- abort_with 'Specify a target: crosspack build ubuntu-24.04 (or --all for every host-buildable entry)' unless positional
157
- target = positional
198
+ target = positional || host_target!('build')
158
199
  end
159
200
  begin
160
201
  result = Crossbuild::Builder.new(build_manifest, root: options[:root],
@@ -163,12 +204,12 @@ when 'build'
163
204
  rescue Crossbuild::Error, Crosspack::InvalidManifestError => e
164
205
  abort_with e.message
165
206
  end
166
- puts "\n📦 #{result.entries.size} entr#{result.entries.size == 1 ? 'y' : 'ies'} built, version #{result.version}"
207
+ puts Crosspack::Colors.green("\n📦 #{result.entries.size} entr#{result.entries.size == 1 ? 'y' : 'ies'} built, version #{result.version}")
167
208
  puts 'Pack with: crosspack pack <target>'
168
209
  when 'pack'
169
- abort_with 'Specify a target: crosspack pack debian-12' unless positional
210
+ target_str = positional || host_target!('pack')
170
211
  begin
171
- target = Crosspack::Target.parse(positional, arch: options[:arch])
212
+ target = Crosspack::Target.parse(target_str, arch: options[:arch])
172
213
  path = Crosspack.pack(
173
214
  config: config,
174
215
  target: target,
@@ -177,11 +218,37 @@ when 'pack'
177
218
  output_base: options[:output_base] || 'crosspacks'
178
219
  )
179
220
  rescue Crosspack::Target::Error => e
180
- abort_with "Invalid target #{positional.inspect}: #{e.message}"
221
+ abort_with "Invalid target #{target_str.inspect}: #{e.message}"
181
222
  rescue Crosspack::InvalidManifestError, Crosspack::ResolveError, Crosspack::BuildError => e
182
223
  abort_with e.message
183
224
  end
184
- puts "📦 #{path}"
225
+ puts Crosspack::Colors.green("📦 #{path}")
226
+ when 'run', 'install'
227
+ # Host-only commands: no target argument; anything after `--` goes to the
228
+ # launched process (crosspack run -- --dev).
229
+ if positional
230
+ abort_with "crosspack #{command} works only for the current host — no target argument " \
231
+ "(other targets make no sense here)"
232
+ end
233
+ args = ARGV.dup
234
+ args.shift if args.first == '--'
235
+ begin
236
+ status =
237
+ if command == 'run'
238
+ Crosspack::Launcher.run(
239
+ config, root: options[:root], args: args,
240
+ output_base: options[:output_base], version: options[:version], deps: !options[:no_deps]
241
+ )
242
+ else
243
+ Crosspack::Launcher.install(
244
+ config, root: options[:root], args: args,
245
+ output_base: options[:output_base] || 'crosspacks'
246
+ )
247
+ end
248
+ rescue Crosspack::InvalidManifestError, Crosspack::BuildError, Crossbuild::Error => e
249
+ abort_with e.message
250
+ end
251
+ exit(status) unless status.zero?
185
252
  when 'targets'
186
253
  # What can actually be packed: builds tree + resolvable deps per target.
187
254
  pkg = config.package_manifest
@@ -189,5 +256,5 @@ when 'targets'
189
256
  root = sources ? File.expand_path(sources, options[:root]) : File.join(options[:root], 'builds')
190
257
  puts Crosspack::Builds.report(root)
191
258
  else
192
- abort_with "Unknown command #{command.inspect}. Available: deps, build, pack, validate, resolve, matrix, version, targets"
259
+ abort_with "Unknown command #{command.inspect}. Available: deps, build, pack, run, install, validate, resolve, matrix, version, targets"
193
260
  end
@@ -143,7 +143,8 @@ module Crossbuild
143
143
  if matches.empty?
144
144
  raise Error,
145
145
  "no matrix entry distributes to target #{raw.inspect}; " \
146
- "declared targets: #{declared.join(', ')}"
146
+ "declared targets: #{declared.join(', ')}. Pass one of them explicitly, " \
147
+ "or add #{raw.inspect} to the entry's artifacts.to in crosspack.yml"
147
148
  end
148
149
  if matches.size > 1
149
150
  raise Error,
@@ -53,8 +53,10 @@ module Crossbuild
53
53
  return ' (no deps declared)' if @manifest.deps.empty?
54
54
 
55
55
  (@statuses || check_all).map do |s|
56
+ state = format('%-8s', s.installed ? 'ok' : 'MISSING')
57
+ state = Crosspack::Colors.public_send(s.installed ? :green : :red, state)
56
58
  format(' %-20s %-8s %-8s %s',
57
- s.dep, s.installed ? 'ok' : 'MISSING', s.selector, s.command)
59
+ s.dep, state, s.selector, s.command)
58
60
  end.join("\n")
59
61
  end
60
62
 
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'colorize'
4
+
5
+ module Crosspack
6
+ # CLI message coloring (green = success, red = errors, yellow = warnings),
7
+ # powered by the colorize gem. Colors are turned off automatically when the
8
+ # output is not a TTY or NO_COLOR is set, so piped and logged output stays
9
+ # clean.
10
+ module Colors
11
+ class << self
12
+ attr_writer :enabled
13
+
14
+ def enabled?
15
+ return @enabled unless @enabled.nil?
16
+
17
+ ENV['NO_COLOR'].nil? && ($stdout.tty? || $stderr.tty?)
18
+ end
19
+
20
+ def red(text)
21
+ enabled? ? text.to_s.colorize(:red) : text
22
+ end
23
+
24
+ def green(text)
25
+ enabled? ? text.to_s.colorize(:green) : text
26
+ end
27
+
28
+ def yellow(text)
29
+ enabled? ? text.to_s.colorize(:yellow) : text
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crosspack
4
+ # Parses and validates the optional run:/install: sections of crosspack.yml.
5
+ # Both map host selectors (the same vocabulary as the deps: rules of the
6
+ # build: section: distro id from os-release, ID_LIKE token, os name, "*")
7
+ # to a shell command with {{path}} and the usual build facts:
8
+ #
9
+ # run:
10
+ # hosts:
11
+ # darwin: open build/bin/MyApp.app
12
+ # "*": "{{path}} --dev"
13
+ #
14
+ # A section is optional; when absent the host-only default applies
15
+ # (see Launcher).
16
+ class CommandManifest
17
+ SECTION_KEYS = %w[hosts].freeze
18
+ # A host selector: "*" or a distro id / ID_LIKE token / os name —
19
+ # the same vocabulary as the deps: rules of the build: section.
20
+ SELECTOR_RE = /\A(\*|[a-z0-9][a-z0-9+._-]*)\z/i.freeze
21
+
22
+ attr_reader :path, :section, :errors, :warnings
23
+
24
+ def initialize(path, raw, section)
25
+ @path = path
26
+ @section = section
27
+ @raw = raw
28
+ @hosts = {}
29
+ @errors = []
30
+ @warnings = []
31
+ validate
32
+ end
33
+
34
+ def present?
35
+ !@raw.nil?
36
+ end
37
+
38
+ def valid?
39
+ @errors.empty?
40
+ end
41
+
42
+ # The command for the given host selectors (see Crossbuild::Platform
43
+ # .selectors), first match wins; nil when nothing applies.
44
+ def command_for(selectors)
45
+ selectors.each { |s| return @hosts[s] if @hosts.key?(s) }
46
+ nil
47
+ end
48
+
49
+ def error_report
50
+ return "#{@path}: the #{@section}: section is absent — defaults apply" unless present?
51
+
52
+ lines = ["#{@path}: the #{@section}: section is #{@errors.empty? ? 'valid' : "invalid (#{@errors.size} errors)"}:"]
53
+ @errors.each { |e| lines << " ✗ #{e}" }
54
+ @warnings.each { |w| lines << " ⚠ #{w}" }
55
+ lines.join("\n")
56
+ end
57
+
58
+ def validate!
59
+ return self if valid?
60
+
61
+ raise InvalidManifestError, error_report
62
+ end
63
+
64
+ private
65
+
66
+ def validate
67
+ return unless present?
68
+
69
+ unless @raw.is_a?(Hash) && !@raw.empty?
70
+ @errors << Issue.new("#{@section}", "must be a non-empty mapping with the key: hosts; expected:\n" \
71
+ " #{@section}:\n" \
72
+ " hosts:\n" \
73
+ " ubuntu: #{@section == 'install' ? 'xdg-open {{path}}' : '{{path}} --dev'}")
74
+ return
75
+ end
76
+ check_unknown_keys
77
+
78
+ hosts = @raw['hosts']
79
+ return unless hosts
80
+
81
+ unless hosts.is_a?(Hash) && !hosts.empty?
82
+ @errors << Issue.new("#{@section}.hosts", 'must be a non-empty mapping of host selector -> shell command')
83
+ return
84
+ end
85
+
86
+ hosts.each do |selector, command|
87
+ validate_rule(selector.to_s, command)
88
+ end
89
+ end
90
+
91
+ def check_unknown_keys
92
+ @raw.keys.map(&:to_s).each do |key|
93
+ next if SECTION_KEYS.include?(key)
94
+
95
+ @errors << Issue.new("#{@section}.#{key}", "unknown key; allowed: #{SECTION_KEYS.join(', ')}")
96
+ end
97
+ end
98
+
99
+ def validate_rule(selector, command)
100
+ path = "#{@section}.hosts.#{selector}"
101
+ unless selector =~ SELECTOR_RE
102
+ @errors << Issue.new(path, "invalid host selector (expected a distro id, os name or \"*\")")
103
+ return
104
+ end
105
+ unless command.is_a?(String) && !command.strip.empty?
106
+ @errors << Issue.new(path, 'must be a non-empty shell command')
107
+ end
108
+ @hosts[selector] = command if command.is_a?(String) && !command.strip.empty?
109
+ end
110
+ end
111
+ end
@@ -15,7 +15,7 @@ module Crosspack
15
15
  # happens per section, not per file.
16
16
  class Config
17
17
  DEFAULT_PATH = 'crosspack.yml'
18
- TOP_LEVEL_KEYS = %w[name version build package deps].freeze
18
+ TOP_LEVEL_KEYS = %w[name version build package deps run install].freeze
19
19
  NAME_RE = /\A[a-z0-9][a-z0-9+._-]*\z/i.freeze
20
20
 
21
21
  attr_reader :path, :errors, :warnings
@@ -81,6 +81,15 @@ module Crosspack
81
81
  @deps_manifest ||= Manifest.new(@path, deps_section)
82
82
  end
83
83
 
84
+ # The optional run:/install: sections (host-only launch commands).
85
+ def run_manifest
86
+ @run_manifest ||= CommandManifest.new(@path, @raw['run'], 'run')
87
+ end
88
+
89
+ def install_manifest
90
+ @install_manifest ||= CommandManifest.new(@path, @raw['install'], 'install')
91
+ end
92
+
84
93
  private
85
94
 
86
95
  def validate
@@ -124,7 +133,7 @@ module Crosspack
124
133
  end
125
134
 
126
135
  def check_section_types
127
- %w[build package deps].each do |key|
136
+ %w[build package deps run install].each do |key|
128
137
  next unless @raw.key?(key) && !@raw[key].is_a?(Hash)
129
138
 
130
139
  @errors << Issue.new(key, 'must be a mapping')
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crosspack
4
+ # Host-only launch commands: `crosspack run` (build the host target, then
5
+ # launch the application's main binary) and `crosspack install` (launch the
6
+ # package packed for this host). Both resolve the current host target, so
7
+ # they take no target argument and are meaningless on any other system.
8
+ #
9
+ # Commands may be overridden per host in crosspack.yml (run:/install:
10
+ # sections, selectors like the deps: rules of build:); placeholders:
11
+ # {{path}} plus the usual facts ({{name}}, {{version}}, {{os}}, {{arch}}).
12
+ # Defaults when nothing is configured:
13
+ # run: darwin + .app bundle -> `open {{path}}`, otherwise {{path}}
14
+ # install: deb/rpm -> xdg-open, msi -> msiexec /i, dmg/.app -> open
15
+ class Launcher
16
+ class Error < BuildError; end
17
+
18
+ # Builds the host target first (the same stage `crosspack build` runs),
19
+ # then launches the first package.executables binary from builds/.
20
+ # Returns the launched process's exit code.
21
+ def self.run(config, root: Dir.pwd, args: [], output_base: nil, version: nil,
22
+ deps: true, host_string: nil, host_os: Crossbuild::Platform.os,
23
+ host_arch: Crossbuild::Platform.arch, selectors: Crossbuild::Platform.selectors)
24
+ config = Config.coerce(config, root: root)
25
+ target = host_target('run', host_string, host_arch)
26
+ pkg = validated_package(config)
27
+ build_manifest = config.build_manifest
28
+ build_manifest.validate!
29
+
30
+ Crossbuild::Builder.new(build_manifest, root: root, output_base: output_base,
31
+ version: version, deps: deps,
32
+ host_os: host_os, host_arch: host_arch)
33
+ .run(target: target.to_s)
34
+
35
+ exe = pkg.executables_for(target).first
36
+ if exe.nil?
37
+ raise Error,
38
+ 'run: the package: section does not name the binary to launch — ' \
39
+ "add executables: [#{pkg.name}] to it (the first entry is what `crosspack run` launches)"
40
+ end
41
+
42
+ path = File.join(File.expand_path(target.output_dir(pkg.sources, target.format), root), exe)
43
+ unless File.exist?(path)
44
+ raise Error,
45
+ "run: expected the built binary at #{path}, but it is not there — " \
46
+ 'does the build step really produce it? Check package.executables against the build output'
47
+ end
48
+
49
+ command = config.run_manifest.command_for(selectors) || default_run_command(path, host_os)
50
+ execute(command, path: path, pkg: pkg, target: target, root: root, args: args)
51
+ end
52
+
53
+ # Finds the package packed for this host in crosspacks/ and launches it
54
+ # with the system installer. Returns the launched process's exit code.
55
+ def self.install(config, root: Dir.pwd, args: [], output_base: 'crosspacks',
56
+ host_string: nil, host_os: Crossbuild::Platform.os,
57
+ host_arch: Crossbuild::Platform.arch, selectors: Crossbuild::Platform.selectors)
58
+ config = Config.coerce(config, root: root)
59
+ target = host_target('install', host_string, host_arch)
60
+ pkg = validated_package(config)
61
+
62
+ command = config.install_manifest.command_for(selectors)
63
+ raise Error, pkgbuild_hint(target, root, output_base) if command.nil? && target.format == :pkgbuild
64
+
65
+ # {{path}} resolves to the package file (for arch: the PKGBUILD dir).
66
+ path = target.format == :pkgbuild ? packed_dir(target, root, output_base)
67
+ : package_file(target, pkg, root, output_base)
68
+ command ||= default_install_command(target.format)
69
+
70
+ execute(command, path: path, pkg: pkg, target: target, root: root, args: args)
71
+ end
72
+
73
+ # -- defaults ------------------------------------------------------------
74
+
75
+ # A macOS .app bundle must be opened, not exec'd; plain binaries and
76
+ # Windows exes run directly.
77
+ def self.default_run_command(path, host_os)
78
+ host_os == :darwin && File.directory?(path) ? 'open {{path}}' : '{{path}}'
79
+ end
80
+
81
+ def self.default_install_command(format)
82
+ case format
83
+ when :deb, :rpm then 'xdg-open {{path}}'
84
+ when :winget then 'msiexec /i {{path}}'
85
+ when :cask then 'open {{path}}'
86
+ else
87
+ raise Error, "install: no default installer command for format #{format.inspect} — " \
88
+ 'add an install: section with hosts: rules to crosspack.yml'
89
+ end
90
+ end
91
+
92
+ # -- internals -----------------------------------------------------------
93
+
94
+ class << self
95
+ private
96
+
97
+ # "this host's target" — the same detection the stage commands use.
98
+ def host_target(command, host_string, host_arch)
99
+ raw = host_string || Target.host_string
100
+ if raw.nil?
101
+ raise Error,
102
+ "Could not detect this host's target — crosspack #{command} runs only on this host " \
103
+ '(families: ubuntu-24.04, debian-12, fedora-41, arch, macos, windows-11.0)'
104
+ end
105
+
106
+ Target.parse(raw, arch: host_arch)
107
+ end
108
+
109
+ def validated_package(config)
110
+ pkg = config.package_manifest
111
+ pkg.validate!
112
+ pkg
113
+ end
114
+
115
+ def packed_dir(target, root, output_base)
116
+ File.expand_path(target.output_dir(output_base, target.format), root)
117
+ end
118
+
119
+ # The packed tree must exist before any installer runs — even with a
120
+ # custom command (its {{path}} resolves into this directory).
121
+ def check_packed_dir(target, root, output_base)
122
+ dir = packed_dir(target, root, output_base)
123
+ return if File.directory?(dir)
124
+
125
+ raise Error,
126
+ "Pack stage not done for target #{target}: expected a packed package in #{dir}.\n" \
127
+ "Run: crosspack pack #{target}"
128
+ end
129
+
130
+ # The single package file this host's installer should receive.
131
+ def package_file(target, pkg, root, output_base)
132
+ check_packed_dir(target, root, output_base)
133
+ dir = packed_dir(target, root, output_base)
134
+ file =
135
+ case target.format
136
+ when :deb then newest(dir, '*.deb')
137
+ when :rpm then newest(dir, '*.rpm')
138
+ when :winget then newest(dir, '*.msi')
139
+ when :cask then newest(dir, '*.dmg') || app_bundle(dir, pkg.name)
140
+ end
141
+
142
+ if file.nil?
143
+ raise winget_sources_hint(dir) if target.format == :winget && Dir[File.join(dir, '*.wxs')].any?
144
+
145
+ raise Error,
146
+ "No package for target #{target} in #{dir} — the pack stage produced nothing this " \
147
+ "command recognizes.\nRun: crosspack pack #{target}"
148
+ end
149
+ file
150
+ end
151
+
152
+ def newest(dir, pattern)
153
+ Dir[File.join(dir, pattern)].max_by { |f| File.mtime(f) }
154
+ end
155
+
156
+ def app_bundle(dir, name)
157
+ app = File.join(dir, "#{name}.app")
158
+ File.directory?(app) ? app : nil
159
+ end
160
+
161
+ def winget_sources_hint(dir)
162
+ 'install: the pack stage produced only WiX sources (.wxs) — build the MSI first, ' \
163
+ "see #{File.join(dir, 'BUILD-MSI.txt')}"
164
+ end
165
+
166
+ def pkgbuild_hint(target, root, output_base)
167
+ "install: packing for #{target} generates a PKGBUILD, not a built package — " \
168
+ "install it yourself:\n cd #{packed_dir(target, root, output_base)} && makepkg -si\n" \
169
+ 'or add an install: section with hosts: rules to crosspack.yml'
170
+ end
171
+
172
+ # Expands the {{...}} placeholders, appends extra CLI args and runs the
173
+ # command from the project root, passing the child's exit code through.
174
+ def execute(command, path:, pkg:, target:, root:, args:)
175
+ vars = {
176
+ name: pkg.name,
177
+ version: Builds.version_for(target, File.expand_path(pkg.sources, root)).to_s,
178
+ os: target.family.to_s,
179
+ arch: Crossbuild::Platform.display_arch(target.arch == :any ? :x86_64 : target.arch),
180
+ platform: target.to_s,
181
+ path: path
182
+ }
183
+ command = Crossbuild::Runner.new(root: root, vars: vars).interpolate(command)
184
+ command = "#{command} #{args.join(' ')}" unless args.empty?
185
+
186
+ puts "▶ #{command}"
187
+ result = system(command, chdir: root)
188
+ raise Error, "launch failed (command not found): #{command}" if result.nil?
189
+
190
+ $?.exitstatus || 0
191
+ end
192
+ end
193
+ end
194
+ end
@@ -5,10 +5,25 @@ module Crosspack
5
5
  # WHAT and HOW to pack — payload, executables, symlinks, desktop entry,
6
6
  # icon. Compilation is not crosspack's business; artifacts are picked from
7
7
  # `sources` as-is.
8
+ #
9
+ # Payload entries may carry per-target file names: the key is the common
10
+ # name (used verbatim everywhere unless a selector map says otherwise),
11
+ # and each selector (exact target "windows-11.0", family "windows" or "*")
12
+ # maps to the real file name that target's build produces. An entry with a
13
+ # selector map applies only to targets it covers — a Windows build dir has
14
+ # no .so libraries and vice versa.
8
15
  class PackageManifest
9
16
  REQUIRED = %w[maintainer description sources prefix payload].freeze
10
17
  OPTIONAL = %w[name summary license section lib_dir executables links desktop icon].freeze
11
18
 
19
+ # Payload selectors may name an os in addition to a family: every distro
20
+ # family packs a linux target, so "linux:" covers deb/rpm/PKGBUILD
21
+ # without enumerating them (macos and windows are their own families).
22
+ OS_BY_FAMILY = Hash.new('linux').merge(macos: 'macos', windows: 'windows').freeze
23
+
24
+ # One payload file: its common key plus the per-target name overrides.
25
+ PayloadFile = Struct.new(:key, :names, keyword_init: true)
26
+
12
27
  attr_reader :path, :data, :errors, :warnings
13
28
 
14
29
  def initialize(path, raw)
@@ -17,6 +32,7 @@ module Crosspack
17
32
  @raw = raw
18
33
  @errors = []
19
34
  @warnings = []
35
+ @payload_entries = []
20
36
  validate
21
37
  end
22
38
 
@@ -72,14 +88,40 @@ module Crosspack
72
88
  @data['prefix']
73
89
  end
74
90
 
91
+ # Common keys of the payload entries (the executables list and links
92
+ # reference these); use payload_for(target) for the real file names.
75
93
  def payload
76
- Array(@data['payload'])
94
+ @payload_entries.map(&:key)
95
+ end
96
+
97
+ # Real file names the given target's package carries. Every entry
98
+ # resolves through its selector map — exact target string, then family,
99
+ # then "*"; entries without a map use their key on every target; entries
100
+ # whose selectors do not cover the target are skipped.
101
+ def payload_for(target)
102
+ @payload_entries.filter_map { |entry| resolve_entry(entry, target) }
77
103
  end
78
104
 
79
105
  def executables
80
106
  Array(@data['executables'])
81
107
  end
82
108
 
109
+ # Executable file names for the target. The executables list names
110
+ # common payload keys; they resolve to the target's real file names.
111
+ def executables_for(target)
112
+ executables.map do |key|
113
+ entry = @payload_entries.find { |e| e.key == key }
114
+ resolved = entry && resolve_entry(entry, target)
115
+ if resolved.nil?
116
+ raise InvalidManifestError,
117
+ "executables: #{key.inspect} resolves to no file for target #{target} — " \
118
+ 'its payload entry has no name for that target'
119
+ end
120
+
121
+ resolved
122
+ end
123
+ end
124
+
83
125
  def summary
84
126
  @data['summary'] || description.lines.map(&:chomp).reject(&:empty?).first || name
85
127
  end
@@ -153,17 +195,91 @@ module Crosspack
153
195
  value = @data['payload']
154
196
  return if value.nil? # already reported as missing
155
197
 
156
- unless value.is_a?(Array) && !value.empty?
157
- @errors << Issue.new('payload', 'must be a non-empty list of artifact files from sources')
158
- return
198
+ @payload_entries =
199
+ if value.is_a?(Array)
200
+ validate_payload_list(value)
201
+ elsif value.is_a?(Hash)
202
+ validate_payload_map(value)
203
+ else
204
+ @errors << Issue.new('payload', 'must be a non-empty list of artifact files or a mapping: key -> {target -> real file name}')
205
+ []
206
+ end
207
+ return unless @payload_entries.empty?
208
+
209
+ @errors << Issue.new('payload', 'must name at least one artifact file from sources')
210
+ end
211
+
212
+ # Legacy/simple form: a flat list — every name applies to every target.
213
+ def validate_payload_list(value)
214
+ value.each_with_index.with_object([]) do |(item, i), entries|
215
+ if item.is_a?(String) && !item.strip.empty? && !item.start_with?('/')
216
+ entries << PayloadFile.new(key: item, names: {})
217
+ else
218
+ @errors << Issue.new("payload[#{i}]", 'file name must be a non-empty relative string (no leading "/")')
219
+ end
159
220
  end
160
- value.each_with_index do |item, i|
161
- next if item.is_a?(String) && !item.strip.empty? && !item.start_with?('/')
221
+ end
162
222
 
163
- @errors << Issue.new("payload[#{i}]", 'file name must be a non-empty relative string (no leading "/")')
223
+ # Mapping form: common key -> null (key everywhere) or a selector map
224
+ # (exact target / family / "*") of real file names per target.
225
+ def validate_payload_map(value)
226
+ value.each_with_object([]) do |(key, names), entries|
227
+ if !key.is_a?(String) || key.strip.empty? || key.start_with?('/')
228
+ @errors << Issue.new("payload.#{key.inspect}", 'must be a non-empty relative file name (no leading "/")')
229
+ next
230
+ end
231
+ if names.nil? || names == {}
232
+ entries << PayloadFile.new(key: key, names: {})
233
+ next
234
+ end
235
+ unless names.is_a?(Hash)
236
+ @errors << Issue.new("payload.#{key}", 'must be null or a mapping of target selector -> real file name')
237
+ next
238
+ end
239
+
240
+ resolved = {}
241
+ entry_valid = true
242
+ names.each do |selector, name|
243
+ unless valid_selector?(selector)
244
+ @errors << Issue.new("payload.#{key}.#{selector}",
245
+ "unknown target selector (allowed: \"*\", an os (linux) or a family " \
246
+ "(#{Target::FAMILIES.join(', ')}), optionally with a version, e.g. windows-11.0)")
247
+ entry_valid = false
248
+ next
249
+ end
250
+ if !name.is_a?(String) || name.strip.empty? || name.start_with?('/')
251
+ @errors << Issue.new("payload.#{key}.#{selector}", 'file name must be a non-empty relative string (no leading "/")')
252
+ entry_valid = false
253
+ next
254
+ end
255
+ resolved[selector] = name
256
+ end
257
+ entries << PayloadFile.new(key: key, names: resolved) if entry_valid
164
258
  end
165
259
  end
166
260
 
261
+ def valid_selector?(selector)
262
+ return true if selector == '*'
263
+ return true if selector == 'linux' # the one os selector (macos/windows are families)
264
+
265
+ Target.parse(selector.to_s)
266
+ true
267
+ rescue Target::Error
268
+ false
269
+ end
270
+
271
+ # Exact target string ("windows-11.0") wins over the family ("windows"),
272
+ # then the os ("linux"), then "*"; an entry without a map carries its
273
+ # key on every target.
274
+ def resolve_entry(entry, target)
275
+ return entry.key if entry.names.empty?
276
+
277
+ entry.names[target.to_s] ||
278
+ entry.names[target.family.to_s] ||
279
+ entry.names[OS_BY_FAMILY[target.family]] ||
280
+ entry.names['*']
281
+ end
282
+
167
283
  def validate_executables
168
284
  value = @data['executables']
169
285
  return if value.nil?
@@ -96,7 +96,7 @@ module Crosspack
96
96
  end
97
97
 
98
98
  lib_dst = File.join(@pkg.prefix, @pkg.lib_dir)
99
- files = @pkg.payload.to_h { |n| [File.join(src_dir, n), File.join(lib_dst, n)] }
99
+ files = @pkg.payload_for(@target).to_h { |n| [File.join(src_dir, n), File.join(lib_dst, n)] }
100
100
 
101
101
  if @pkg.icon
102
102
  icon_src = File.expand_path(@pkg.icon, @root)
@@ -110,7 +110,7 @@ module Crosspack
110
110
  end
111
111
 
112
112
  symlinks = @pkg.links.to_h { |link, target| [File.join(@pkg.prefix, link), target] }
113
- executables = @pkg.executables.map { |exe| File.join(lib_dst, exe) }
113
+ executables = @pkg.executables_for(@target).map { |exe| File.join(lib_dst, exe) }
114
114
 
115
115
  { files: files, symlinks: symlinks, executables: executables,
116
116
  src_dir: src_dir, lib_dst: lib_dst }
@@ -192,7 +192,7 @@ module Crosspack
192
192
  depends: resolved_depends(:pkgbuild),
193
193
  arch: [@target.package_arch(:pkgbuild)],
194
194
  # PKGBUILD sources are archive-relative names, not local paths.
195
- files: @pkg.payload.to_h { |n| [n, File.join(@pkg_data[:lib_dst], n)] },
195
+ files: @pkg.payload_for(@target).to_h { |n| [n, File.join(@pkg_data[:lib_dst], n)] },
196
196
  symlinks: @pkg_data[:symlinks],
197
197
  executables: @pkg_data[:executables],
198
198
  source: ["#{@pkg.name}-#{version}.tar.gz"],
@@ -213,7 +213,7 @@ module Crosspack
213
213
  def pack_windows
214
214
  output = File.join(@target.output_dir(@output_base, :winget),
215
215
  "#{@pkg.name}-#{Builders::Wix.msi_version(@version)}.msi")
216
- files = @pkg.payload.to_h { |n| [File.join(@pkg_data[:src_dir], n), n] }
216
+ files = @pkg.payload_for(@target).to_h { |n| [File.join(@pkg_data[:src_dir], n), n] }
217
217
  Builders::Wix.build(
218
218
  name: @pkg.name,
219
219
  version: @version,
@@ -228,13 +228,13 @@ module Crosspack
228
228
  # macOS: .app bundle staging; DMG itself requires a Mac (hdiutil).
229
229
  def pack_macos
230
230
  output_dir = @target.output_dir(@output_base, :cask)
231
- files = @pkg.payload.to_h { |n| [File.join(@pkg_data[:src_dir], n), n] }
231
+ files = @pkg.payload_for(@target).to_h { |n| [File.join(@pkg_data[:src_dir], n), n] }
232
232
  Builders::AppDir.build(
233
233
  name: @pkg.name,
234
234
  version: @version,
235
235
  summary: @pkg.summary,
236
236
  files: files,
237
- executables: @pkg.executables,
237
+ executables: @pkg.executables_for(@target),
238
238
  output: output_dir
239
239
  )
240
240
  end
@@ -55,6 +55,45 @@ module Crosspack
55
55
  new(family_sym, version, norm_arch)
56
56
  end
57
57
 
58
+ # The target of the machine crosspack runs on, for stage commands invoked
59
+ # without an explicit target: "macos", "windows-11.0" or the distro
60
+ # family + version from the os-release file ("ubuntu-24.04",
61
+ # "debian-12", "fedora-41", "arch"). Unknown distro ids fall back to the
62
+ # first ID_LIKE token that names a family. Returns nil when the host is
63
+ # not recognizable — the CLI then asks for an explicit target.
64
+ # Windows defaults to the 11.0 target vocabulary (10 is EOL); pass a
65
+ # target explicitly when packing for another release.
66
+ def self.host_string(ruby_platform: RUBY_PLATFORM, release_path: '/etc/os-release')
67
+ case ruby_platform
68
+ when /darwin/ then 'macos'
69
+ when /mingw|mswin|cygwin/i then 'windows-11.0'
70
+ when /linux/
71
+ facts = host_os_release(release_path)
72
+ family = ([facts['ID']] + facts['ID_LIKE'].to_s.split)
73
+ .filter_map { |id| id.to_s.to_sym if id }
74
+ .find { |f| FAMILIES.include?(f) }
75
+ return nil if family.nil?
76
+
77
+ version = facts['VERSION_ID'].to_s
78
+ return family.to_s if VERSIONLESS_FAMILIES.include?(family) ||
79
+ OPTIONAL_VERSION_FAMILIES.include?(family)
80
+ return nil if version.empty? # e.g. Debian sid: no pin-able version
81
+
82
+ "#{family}-#{version}"
83
+ end
84
+ end
85
+
86
+ def self.host_os_release(path)
87
+ File.read(path).each_line.with_object({}) do |line, facts|
88
+ key, value = line.split('=', 2)
89
+ next if value.nil?
90
+
91
+ facts[key.strip] = value.strip.delete_prefix('"').delete_suffix('"')
92
+ end
93
+ rescue Errno::ENOENT
94
+ {}
95
+ end
96
+
58
97
  def initialize(family, version = nil, arch = :x86_64)
59
98
  @family = family.to_sym
60
99
  @version = version && version.to_s
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Crosspack
4
- VERSION = '0.5.1'
4
+ VERSION = '0.7.0'
5
5
  end
data/lib/crosspack.rb CHANGED
@@ -1,14 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'crosspack/version'
4
+
5
+ module Crosspack
6
+ # A stage failed (deps/build/pack/launch). Defined before the requires:
7
+ # launcher.rb subclasses it at load time.
8
+ class BuildError < StandardError; end
9
+ end
10
+
11
+ require_relative 'crosspack/colors'
4
12
  require_relative 'crosspack/target'
5
13
  require_relative 'crosspack/manifest'
14
+ require_relative 'crosspack/command_manifest'
6
15
  require_relative 'crosspack/package_manifest'
7
16
  require_relative 'crosspack/config'
8
17
  require_relative 'crosspack/resolver'
9
18
  require_relative 'crosspack/matrix'
10
19
  require_relative 'crosspack/builds'
11
20
  require_relative 'crosspack/packer'
21
+ require_relative 'crosspack/launcher'
12
22
  require_relative 'crosspack/builders/deb'
13
23
  require_relative 'crosspack/builders/rpm'
14
24
  require_relative 'crosspack/builders/pkgbuild'
@@ -29,7 +39,6 @@ module Crosspack
29
39
  # files: { source_path => destination_inside_package (no leading /) }
30
40
  # symlinks: { link_path_inside_package => link_target }
31
41
  # executables: [destination paths to chmod 0755]
32
- class BuildError < StandardError; end
33
42
 
34
43
  # Convenience wrapper: Crosspack.pack(config: 'crosspack.yml',
35
44
  # target: Target.parse('debian-12'), version: '1.0-1')
data/logo.png ADDED
Binary file
metadata CHANGED
@@ -1,15 +1,28 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: crosspack
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.1
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Oleg Orlov
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2026-09-13 00:00:00.000000000 Z
12
- dependencies: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: colorize
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.0'
13
26
  description: 'One command, one config file, one target vocabulary: crosspack deps
14
27
  <target> verifies and installs the build host dependencies, crosspack build <target>
15
28
  runs the crosspack.yml build matrix entry and fans artifacts into builds/, crosspack
@@ -43,7 +56,10 @@ files:
43
56
  - lib/crosspack/builders/rpm.rb
44
57
  - lib/crosspack/builders/wix.rb
45
58
  - lib/crosspack/builds.rb
59
+ - lib/crosspack/colors.rb
60
+ - lib/crosspack/command_manifest.rb
46
61
  - lib/crosspack/config.rb
62
+ - lib/crosspack/launcher.rb
47
63
  - lib/crosspack/manifest.rb
48
64
  - lib/crosspack/matrix.rb
49
65
  - lib/crosspack/package_manifest.rb
@@ -51,13 +67,13 @@ files:
51
67
  - lib/crosspack/resolver.rb
52
68
  - lib/crosspack/target.rb
53
69
  - lib/crosspack/version.rb
70
+ - logo.png
54
71
  homepage: https://github.com/OrelSokolov/crosspack
55
72
  licenses:
56
73
  - Nonstandard
57
74
  metadata:
58
75
  source_code_uri: https://github.com/OrelSokolov/crosspack
59
76
  changelog_uri: https://github.com/OrelSokolov/crosspack/blob/main/CHANGELOG.md
60
- post_install_message:
61
77
  rdoc_options: []
62
78
  require_paths:
63
79
  - lib
@@ -72,8 +88,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
72
88
  - !ruby/object:Gem::Version
73
89
  version: '0'
74
90
  requirements: []
75
- rubygems_version: 3.5.22
76
- signing_key:
91
+ rubygems_version: 3.6.9
77
92
  specification_version: 4
78
93
  summary: 'One config (crosspack.yml), staged pipeline: deps → build → pack native
79
94
  packages (deb/rpm/PKGBUILD/winget/cask)'