wslc-wip 0.1.1
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 +7 -0
- data/LICENSE +21 -0
- data/README.md +141 -0
- data/exe/wip +11 -0
- data/lib/wip/cli.rb +83 -0
- data/lib/wip/command_builder.rb +68 -0
- data/lib/wip/command_resolver.rb +41 -0
- data/lib/wip/command_runner.rb +45 -0
- data/lib/wip/config.rb +67 -0
- data/lib/wip/config_loader.rb +36 -0
- data/lib/wip/doctor.rb +67 -0
- data/lib/wip/environment.rb +26 -0
- data/lib/wip/error_interpreter.rb +49 -0
- data/lib/wip/errors.rb +7 -0
- data/lib/wip/version.rb +5 -0
- data/lib/wip.rb +13 -0
- metadata +69 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: a8e5f43bc1610d2c7af26c4ed9052ca4a055bcbbc97957f756452b5992b020b3
|
|
4
|
+
data.tar.gz: 24299f3953026ccde81f3bc485234769aa417bac0bc6a22ab37c82ce76d1c8ac
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 643895970056693e00703eddbecb9303fafd798bfe1f2ea005435f5b6daf36678e687784a75105fb88dbc01ca524060d4a9b8862ab3b38fce8121a577bcd868f
|
|
7
|
+
data.tar.gz: 0542772f24b25763b610aba4d940f589bae73327f444103d77e9c899b6376f73ffcb78ce6a56c91fd180a5c1ac053e0ea8e5269008919d19541036d7d0550aa0
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 slidict
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# wip
|
|
2
|
+
|
|
3
|
+
`wip` は、Docker 開発環境 CLI の dip に近い操作感を Microsoft WSLC に提供する Ruby 製 OSS CLI ラッパーです。プロジェクト固有のコンテナ、イメージ、環境変数やコマンドを `wip.yml` にまとめ、安全な引数配列として `wslc.exe` / `wslc` に渡します。
|
|
4
|
+
|
|
5
|
+
> **Status:** 初期リリース 0.1.0。WSLC 自体の仕様変更に追随が必要になる可能性があります。
|
|
6
|
+
|
|
7
|
+
## 対応環境とインストール
|
|
8
|
+
|
|
9
|
+
Ruby 3.2 以上、WSL2、および Microsoft WSLC が必要です。RubyGems 公開後は次で導入できます。
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
gem install wip
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
ソースからは `bundle install && bundle exec exe/wip version` を使用します。
|
|
16
|
+
|
|
17
|
+
## クイックスタート
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
gem install wip
|
|
21
|
+
cd my-project
|
|
22
|
+
wip doctor
|
|
23
|
+
wip build
|
|
24
|
+
wip rails console
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## 完全な設定例
|
|
28
|
+
|
|
29
|
+
プロジェクトルートに `wip.yml` を置きます。子ディレクトリから実行しても親方向へ探索され、`--config PATH` で明示指定もできます。
|
|
30
|
+
|
|
31
|
+
```yaml
|
|
32
|
+
version: 1
|
|
33
|
+
wslc:
|
|
34
|
+
command: auto # wslc.exe、wslc、System32 の順。絶対パスも指定可能
|
|
35
|
+
defaults:
|
|
36
|
+
container: app
|
|
37
|
+
image: slidict/slidict:development
|
|
38
|
+
workdir: /app
|
|
39
|
+
interactive: false
|
|
40
|
+
remove: true
|
|
41
|
+
env:
|
|
42
|
+
RAILS_ENV: development
|
|
43
|
+
PORT: "3000"
|
|
44
|
+
ports:
|
|
45
|
+
- "3000:3000"
|
|
46
|
+
volumes:
|
|
47
|
+
- ".:/app"
|
|
48
|
+
commands:
|
|
49
|
+
rails:
|
|
50
|
+
type: exec
|
|
51
|
+
command: bin/rails
|
|
52
|
+
container: app
|
|
53
|
+
interactive: true
|
|
54
|
+
workdir: /app
|
|
55
|
+
env:
|
|
56
|
+
RAILS_ENV: development
|
|
57
|
+
bundle:
|
|
58
|
+
command: bundle
|
|
59
|
+
rspec:
|
|
60
|
+
command: bundle exec rspec
|
|
61
|
+
shell:
|
|
62
|
+
command: bash
|
|
63
|
+
interactive: true
|
|
64
|
+
migrate:
|
|
65
|
+
type: run
|
|
66
|
+
command: bundle exec rails db:migrate
|
|
67
|
+
image: slidict/slidict:development
|
|
68
|
+
remove: true
|
|
69
|
+
build:
|
|
70
|
+
type: build
|
|
71
|
+
context: .
|
|
72
|
+
tag: slidict/slidict:development
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`env` の値は文字列化されます。`config` の表示では token、password、secret、credential、auth を含むキーを伏せます。秘密値は可能なら設定ファイルではなく実行環境側で管理してください。
|
|
76
|
+
|
|
77
|
+
## コマンド
|
|
78
|
+
|
|
79
|
+
| コマンド | 説明 |
|
|
80
|
+
|---|---|
|
|
81
|
+
| `wip version` | wip と、検出できた WSLC のバージョン |
|
|
82
|
+
| `wip doctor` | WSL2、相互運用、WSLC、設定、アーキテクチャ、Git を診断 |
|
|
83
|
+
| `wip config` | デフォルト適用済み設定(秘密値をマスク) |
|
|
84
|
+
| `wip build -- --no-cache` | build 定義でイメージをビルド |
|
|
85
|
+
| `wip exec [--no-interactive] COMMAND...` | 既存コンテナ内で実行 |
|
|
86
|
+
| `wip run [--no-interactive] COMMAND...` | `--rm` の新規コンテナで実行 |
|
|
87
|
+
| `wip shell` | 定義済み shell、または `bash`、`sh` の順に起動 |
|
|
88
|
+
| `wip NAME ARGS...` | `commands.NAME` を実行し、引数を末尾に追加 |
|
|
89
|
+
|
|
90
|
+
TTY はコマンド設定、CLI オプション、標準入出力が双方 TTY かを合わせて判定します。`WIP_DEBUG=1` では `Shellwords.join` した実行コマンドを表示します。
|
|
91
|
+
|
|
92
|
+
## doctor
|
|
93
|
+
|
|
94
|
+
`[OK]`、`[WARN]`、`[FAIL]` で各診断を表示します。警告だけなら終了コード 0、WSL2/相互運用/WSLC/設定など実行を妨げる問題があれば 1 です。実際のビルド環境から Git が見えない場合は警告になります。
|
|
95
|
+
|
|
96
|
+
## よくあるエラー
|
|
97
|
+
|
|
98
|
+
### WSLC がない
|
|
99
|
+
|
|
100
|
+
WSLC/WSL コンテナツールをインストールまたは更新し、`wip doctor` を実行してください。`auto` は `wslc.exe`、`wslc`、`/mnt/c/Windows/System32/wslc.exe` の順で探します。
|
|
101
|
+
|
|
102
|
+
### Docker Hub の認証
|
|
103
|
+
|
|
104
|
+
`pull access denied` 等を検出するとログイン方法を補足します。
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
wslc registry login -u <username> docker.io
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### CPU アーキテクチャ不一致
|
|
111
|
+
|
|
112
|
+
イメージを確認し、amd64/arm64 のマルチアーキテクチャイメージを公開します。
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
docker buildx imagetools inspect <image>
|
|
116
|
+
docker buildx build \
|
|
117
|
+
--platform linux/amd64,linux/arm64 \
|
|
118
|
+
-t <image> \
|
|
119
|
+
--push .
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## 開発
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
git clone <repository-url>
|
|
126
|
+
cd wip
|
|
127
|
+
bundle install
|
|
128
|
+
bundle exec rspec
|
|
129
|
+
bundle exec rubocop
|
|
130
|
+
bundle exec rake
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
テストは WSLC を必要とせず、解決・構築・実行層を差し替え可能にしています。GitHub Actions は Ruby 3.2、3.3、3.4 で RSpec と RuboCop を実行します。
|
|
134
|
+
|
|
135
|
+
## 初期リリースに含まれないもの
|
|
136
|
+
|
|
137
|
+
Compose 互換、常駐プロセス、GUI、PowerShell 最適化、レジストリ API/manifest の直接解析、自動更新、プラグインは未実装です。将来は設定スキーマ、ライフサイクルフック、複数コンテナ、プラットフォーム選択、より詳細な診断の追加が有用です。
|
|
138
|
+
|
|
139
|
+
## License
|
|
140
|
+
|
|
141
|
+
[MIT License](LICENSE)
|
data/exe/wip
ADDED
data/lib/wip/cli.rb
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'thor'
|
|
4
|
+
require 'yaml'
|
|
5
|
+
|
|
6
|
+
module Wip
|
|
7
|
+
# Thor-based command-line interface for wip.
|
|
8
|
+
class CLI < Thor
|
|
9
|
+
class_option :config, type: :string, desc: 'Path to wip.yml'
|
|
10
|
+
default_task :dispatch
|
|
11
|
+
|
|
12
|
+
desc 'version', 'Show wip and WSLC versions'
|
|
13
|
+
def version
|
|
14
|
+
puts "wip #{VERSION}"
|
|
15
|
+
config = load_config
|
|
16
|
+
command = resolver.resolve(config.wslc_command)
|
|
17
|
+
system(command, 'version')
|
|
18
|
+
rescue Error
|
|
19
|
+
nil
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
desc 'doctor', 'Diagnose the development environment'
|
|
23
|
+
def doctor
|
|
24
|
+
results = Doctor.new(loader: loader).call
|
|
25
|
+
results.each { |item| puts "[#{item.level.to_s.upcase}] #{item.message}" }
|
|
26
|
+
exit(1) if results.any? { |item| item.level == :fail }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
desc 'config', 'Print the effective configuration'
|
|
30
|
+
def config
|
|
31
|
+
puts YAML.dump(load_config.to_h)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
desc 'build [OPTIONS]', 'Build the configured image'
|
|
35
|
+
def build(*extra)
|
|
36
|
+
extra.shift if extra.first == '--'
|
|
37
|
+
execute(builder.build(settings: load_config.command('build') || {}, extra: extra))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
desc 'exec COMMAND...', 'Execute a command in the running container'
|
|
41
|
+
option :interactive, type: :boolean, default: true
|
|
42
|
+
def exec(*command)
|
|
43
|
+
execute(builder.exec(command, interactive: options[:interactive]))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
desc 'run COMMAND...', 'Run a command in a new container'
|
|
47
|
+
option :interactive, type: :boolean, default: true
|
|
48
|
+
def run_command(*command)
|
|
49
|
+
execute(builder.run(command, interactive: options[:interactive]))
|
|
50
|
+
end
|
|
51
|
+
map 'run' => :run_command
|
|
52
|
+
|
|
53
|
+
desc 'shell', 'Open a shell in the configured container'
|
|
54
|
+
def shell_command
|
|
55
|
+
configured = load_config.command('shell')
|
|
56
|
+
return execute(builder.custom('shell', [])) if configured
|
|
57
|
+
|
|
58
|
+
code = execute(builder.exec(['bash'], settings: { 'interactive' => true }, interactive: true))
|
|
59
|
+
execute(builder.exec(['sh'], settings: { 'interactive' => true }, interactive: true)) if code != 0
|
|
60
|
+
end
|
|
61
|
+
map 'shell' => :shell_command
|
|
62
|
+
|
|
63
|
+
desc 'dispatch COMMAND [ARGS...]', 'Run a command defined in wip.yml'
|
|
64
|
+
def dispatch(name = nil, *arguments)
|
|
65
|
+
raise ConfigError, 'A command is required' unless name
|
|
66
|
+
|
|
67
|
+
execute(builder.custom(name, arguments))
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def loader = ConfigLoader.new(path: options[:config])
|
|
73
|
+
def load_config = (@load_config ||= loader.load)
|
|
74
|
+
def resolver = CommandResolver.new
|
|
75
|
+
def builder = CommandBuilder.new(wslc: resolver.resolve(load_config.wslc_command), config: load_config)
|
|
76
|
+
|
|
77
|
+
def execute(command)
|
|
78
|
+
code = CommandRunner.new.run(command)
|
|
79
|
+
exit(code) unless code.zero?
|
|
80
|
+
code
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'shellwords'
|
|
4
|
+
|
|
5
|
+
module Wip
|
|
6
|
+
# Builds the argument arrays for wslc build/exec/run/custom invocations.
|
|
7
|
+
class CommandBuilder
|
|
8
|
+
def initialize(wslc:, config:, environment: Environment.new)
|
|
9
|
+
@wslc = wslc
|
|
10
|
+
@config = config
|
|
11
|
+
@environment = environment
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def exec(arguments, settings: {}, interactive: true)
|
|
15
|
+
values = @config.defaults.merge(settings)
|
|
16
|
+
command = [@wslc, 'exec']
|
|
17
|
+
command << '-it' if tty?(interactive)
|
|
18
|
+
command.concat(options(values, include_container: true)).concat(arguments)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def run(arguments, settings: {}, interactive: true)
|
|
22
|
+
values = @config.defaults.merge(settings)
|
|
23
|
+
command = [@wslc, 'run']
|
|
24
|
+
command << '--rm' if values['remove']
|
|
25
|
+
command << '-it' if tty?(interactive)
|
|
26
|
+
command.concat(options(values)).push(required(values, 'image')).concat(arguments)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def build(settings:, extra: [])
|
|
30
|
+
values = @config.defaults.merge(settings)
|
|
31
|
+
context = values['context'] || '.'
|
|
32
|
+
tag = values['tag'] || values['image']
|
|
33
|
+
raise ConfigError, 'Build image/tag must not be empty' if tag.to_s.empty?
|
|
34
|
+
|
|
35
|
+
[@wslc, 'build', '-t', tag, *extra, context]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def custom(name, arguments)
|
|
39
|
+
values = @config.command(name) || raise(ConfigError, "Unknown command: #{name}")
|
|
40
|
+
type = values['type'] || (name.to_s == 'build' ? 'build' : 'exec')
|
|
41
|
+
base = Shellwords.split(values['command'].to_s)
|
|
42
|
+
return build(settings: values, extra: arguments) if type == 'build'
|
|
43
|
+
|
|
44
|
+
public_send(type, base + arguments, settings: values, interactive: values.fetch('interactive', false))
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def options(values, include_container: false)
|
|
50
|
+
result = []
|
|
51
|
+
result.push('-w', values['workdir']) unless values['workdir'].to_s.empty?
|
|
52
|
+
values.fetch('env', {}).each { |key, value| result.push('-e', "#{key}=#{value}") }
|
|
53
|
+
Array(values['ports']).each { |port| result.push('-p', port.to_s) }
|
|
54
|
+
Array(values['volumes']).each { |volume| result.push('-v', volume.to_s) }
|
|
55
|
+
result << required(values, 'container') if include_container
|
|
56
|
+
result
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def required(values, key)
|
|
60
|
+
value = values[key]
|
|
61
|
+
raise ConfigError, "Configured #{key} must not be empty" if value.to_s.empty?
|
|
62
|
+
|
|
63
|
+
value
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def tty?(requested) = requested && @environment.interactive?
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wip
|
|
4
|
+
# Locates the wslc/wslc.exe executable on the current system.
|
|
5
|
+
class CommandResolver
|
|
6
|
+
CANDIDATES = ['wslc.exe', 'wslc', '/mnt/c/Windows/System32/wslc.exe'].freeze
|
|
7
|
+
|
|
8
|
+
def initialize(path: ENV.fetch('PATH', ''), executable: nil)
|
|
9
|
+
@path = path
|
|
10
|
+
@executable = executable || method(:executable?)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def resolve(configured = 'auto')
|
|
14
|
+
return configured if configured != 'auto' && @executable.call(configured)
|
|
15
|
+
return raise_not_found if configured != 'auto'
|
|
16
|
+
|
|
17
|
+
CANDIDATES.find { |candidate| @executable.call(candidate) } || raise_not_found
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def executable?(command)
|
|
23
|
+
return File.executable?(command) if command.include?(File::SEPARATOR)
|
|
24
|
+
|
|
25
|
+
@path.split(File::PATH_SEPARATOR).any? { |directory| File.executable?(File.join(directory, command)) }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def raise_not_found
|
|
29
|
+
raise CommandNotFoundError, <<~MESSAGE
|
|
30
|
+
WSLC was not found.
|
|
31
|
+
|
|
32
|
+
Checked:
|
|
33
|
+
#{CANDIDATES.join("\n ")}
|
|
34
|
+
|
|
35
|
+
Install or update the WSL container tooling, then run:
|
|
36
|
+
|
|
37
|
+
wip doctor
|
|
38
|
+
MESSAGE
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
require 'shellwords'
|
|
5
|
+
|
|
6
|
+
module Wip
|
|
7
|
+
# Executes a built command, pumping its I/O and returning the exit status.
|
|
8
|
+
class CommandRunner
|
|
9
|
+
def initialize(stdin: $stdin, stdout: $stdout, stderr: $stderr, interpreter: ErrorInterpreter.new)
|
|
10
|
+
@stdin = stdin
|
|
11
|
+
@stdout = stdout
|
|
12
|
+
@stderr = stderr
|
|
13
|
+
@interpreter = interpreter
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def run(command, env: {})
|
|
17
|
+
@stderr.puts "+ #{Shellwords.join(command)}" if ENV['WIP_DEBUG']
|
|
18
|
+
captured = +''
|
|
19
|
+
status = nil
|
|
20
|
+
Open3.popen3(env, *command) do |input, output, error, wait|
|
|
21
|
+
input.close
|
|
22
|
+
threads = [pump(output, @stdout, captured), pump(error, @stderr, captured)]
|
|
23
|
+
threads.each(&:join)
|
|
24
|
+
status = wait.value
|
|
25
|
+
end
|
|
26
|
+
hint = @interpreter.interpret(captured)
|
|
27
|
+
@stderr.puts("\n#{hint}") if !status.success? && hint
|
|
28
|
+
status.exitstatus
|
|
29
|
+
rescue Errno::ENOENT => e
|
|
30
|
+
@stderr.puts e.message
|
|
31
|
+
127
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def pump(source, destination, captured)
|
|
37
|
+
Thread.new do
|
|
38
|
+
source.each(4096) do |chunk|
|
|
39
|
+
destination.write(chunk)
|
|
40
|
+
captured << chunk
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
data/lib/wip/config.rb
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wip
|
|
4
|
+
# Validated, defaulted access to a parsed wip.yml document.
|
|
5
|
+
class Config
|
|
6
|
+
DEFAULTS = { 'container' => 'app', 'workdir' => '/app', 'interactive' => false,
|
|
7
|
+
'remove' => true, 'env' => {}, 'ports' => [], 'volumes' => [] }.freeze
|
|
8
|
+
SECRET_PATTERN = /token|password|secret|credential|auth/i
|
|
9
|
+
attr_reader :path
|
|
10
|
+
|
|
11
|
+
def initialize(raw, path = nil)
|
|
12
|
+
@raw = stringify(raw)
|
|
13
|
+
@path = path
|
|
14
|
+
validate!
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def wslc_command = @raw.dig('wslc', 'command') || 'auto'
|
|
18
|
+
def commands = @raw['commands'] || {}
|
|
19
|
+
def defaults = DEFAULTS.merge(@raw['defaults'] || {})
|
|
20
|
+
|
|
21
|
+
def command(name)
|
|
22
|
+
entry = commands[name.to_s]
|
|
23
|
+
return unless entry
|
|
24
|
+
|
|
25
|
+
defaults.merge('type' => 'exec').merge(entry)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def to_h(redact: true)
|
|
29
|
+
value = { 'version' => 1, 'wslc' => { 'command' => wslc_command }, 'defaults' => defaults,
|
|
30
|
+
'commands' => commands.transform_values { |entry| defaults.merge('type' => 'exec').merge(entry) } }
|
|
31
|
+
redact ? redact_secrets(value) : value
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def validate!
|
|
37
|
+
raise ConfigError, "Unsupported configuration version: #{@raw['version']}" unless (@raw['version'] || 1) == 1
|
|
38
|
+
raise ConfigError, 'commands must be a mapping' unless commands.is_a?(Hash)
|
|
39
|
+
|
|
40
|
+
commands.each { |name, entry| validate_command!(name, entry) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def validate_command!(name, entry)
|
|
44
|
+
raise ConfigError, "commands.#{name} must be a mapping" unless entry.is_a?(Hash)
|
|
45
|
+
|
|
46
|
+
type = entry['type'] || (name == 'build' ? 'build' : 'exec')
|
|
47
|
+
raise ConfigError, "Invalid command type for #{name}: #{type}" unless %w[exec run build].include?(type)
|
|
48
|
+
|
|
49
|
+
entry['env']&.transform_values!(&:to_s)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def stringify(object)
|
|
53
|
+
case object
|
|
54
|
+
when Hash then object.to_h { |key, value| [key.to_s, stringify(value)] }
|
|
55
|
+
when Array then object.map { |value| stringify(value) }
|
|
56
|
+
else object
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def redact_secrets(object)
|
|
61
|
+
return object.map { |item| redact_secrets(item) } if object.is_a?(Array)
|
|
62
|
+
return object unless object.is_a?(Hash)
|
|
63
|
+
|
|
64
|
+
object.to_h { |key, value| [key, key.match?(SECRET_PATTERN) ? '[REDACTED]' : redact_secrets(value)] }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'pathname'
|
|
4
|
+
require 'yaml'
|
|
5
|
+
|
|
6
|
+
module Wip
|
|
7
|
+
# Finds and parses wip.yml, searching parent directories when unset.
|
|
8
|
+
class ConfigLoader
|
|
9
|
+
FILENAME = 'wip.yml'
|
|
10
|
+
|
|
11
|
+
def initialize(start_dir: Dir.pwd, path: nil)
|
|
12
|
+
@start_dir = Pathname(start_dir).expand_path
|
|
13
|
+
@path = path
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def find
|
|
17
|
+
return Pathname(@path).expand_path if @path
|
|
18
|
+
|
|
19
|
+
@start_dir.ascend do |directory|
|
|
20
|
+
candidate = directory.join(FILENAME)
|
|
21
|
+
return candidate if candidate.file?
|
|
22
|
+
end
|
|
23
|
+
nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def load
|
|
27
|
+
path = find
|
|
28
|
+
raise ConfigError, "wip.yml was not found (searched from #{@start_dir} to the filesystem root)" unless path&.file?
|
|
29
|
+
|
|
30
|
+
data = YAML.safe_load_file(path, permitted_classes: [], aliases: false) || {}
|
|
31
|
+
Config.new(data, path)
|
|
32
|
+
rescue Psych::Exception => e
|
|
33
|
+
raise ConfigError, "Could not parse #{path}: #{e.message}"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
data/lib/wip/doctor.rb
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
|
|
5
|
+
module Wip
|
|
6
|
+
# Runs environment diagnostics and reports pass/warn/fail results.
|
|
7
|
+
class Doctor
|
|
8
|
+
Result = Data.define(:level, :message)
|
|
9
|
+
|
|
10
|
+
def initialize(loader:, resolver: CommandResolver.new, environment: Environment.new)
|
|
11
|
+
@loader = loader
|
|
12
|
+
@resolver = resolver
|
|
13
|
+
@environment = environment
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call
|
|
17
|
+
results = []
|
|
18
|
+
results << result(@environment.wsl2? ? :ok : :fail, 'Running on WSL2', 'Not running on WSL2')
|
|
19
|
+
results << result(@environment.windows_interop? ? :ok : :fail, 'Windows executable interoperability is enabled',
|
|
20
|
+
'Windows executable interoperability is disabled')
|
|
21
|
+
results << Result.new(:ok, "Architecture: #{@environment.architecture}")
|
|
22
|
+
config = load_config(results)
|
|
23
|
+
command = resolve(config, results) if config
|
|
24
|
+
check_version(command, results) if command
|
|
25
|
+
results << result(command_available?('git') ? :ok : :warn, 'Git is available',
|
|
26
|
+
'Git is not available to the WSLC build environment')
|
|
27
|
+
results
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def load_config(results)
|
|
33
|
+
config = @loader.load
|
|
34
|
+
invalid = %w[container image].select { |key| config.defaults[key].to_s.empty? }
|
|
35
|
+
results << Result.new(invalid.empty? ? :ok : :fail,
|
|
36
|
+
invalid.empty? ? 'Loaded wip.yml' : "Empty defaults: #{invalid.join(', ')}")
|
|
37
|
+
config
|
|
38
|
+
rescue ConfigError => e
|
|
39
|
+
results << Result.new(:fail, e.message)
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def resolve(config, results)
|
|
44
|
+
command = @resolver.resolve(config.wslc_command)
|
|
45
|
+
results << Result.new(:ok, "Found #{command}")
|
|
46
|
+
command
|
|
47
|
+
rescue CommandNotFoundError => e
|
|
48
|
+
results << Result.new(:fail, e.message)
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def check_version(command, results)
|
|
53
|
+
_output, status = Open3.capture2e(command, 'version')
|
|
54
|
+
results << Result.new(status.success? ? :ok : :fail,
|
|
55
|
+
status.success? ? 'WSLC is available' : 'WSLC version failed')
|
|
56
|
+
rescue Errno::ENOENT
|
|
57
|
+
results << Result.new(:fail, 'WSLC version failed')
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def result(condition, ok_message, fail_message)
|
|
61
|
+
Result.new(condition,
|
|
62
|
+
condition == :ok ? ok_message : fail_message)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def command_available?(name) = ENV.fetch('PATH', '').split(File::PATH_SEPARATOR).any? { |dir| File.executable?(File.join(dir, name)) }
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wip
|
|
4
|
+
# Detects WSL2, Windows interop, and architecture facts about the host.
|
|
5
|
+
class Environment
|
|
6
|
+
def initialize(stdin: $stdin, stdout: $stdout)
|
|
7
|
+
@stdin = stdin
|
|
8
|
+
@stdout = stdout
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def wsl2?
|
|
12
|
+
File.read('/proc/version').match?(/microsoft.*WSL2/i)
|
|
13
|
+
rescue Errno::ENOENT
|
|
14
|
+
false
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def windows_interop? = File.executable?('/proc/sys/fs/binfmt_misc/WSLInterop') || ENV.key?('WSL_INTEROP')
|
|
18
|
+
def interactive? = @stdin.tty? && @stdout.tty?
|
|
19
|
+
|
|
20
|
+
def architecture
|
|
21
|
+
machine = `uname -m`.strip
|
|
22
|
+
{ 'x86_64' => 'linux/amd64', 'aarch64' => 'linux/arm64', 'arm64' => 'linux/arm64' }.fetch(machine,
|
|
23
|
+
"linux/#{machine}")
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wip
|
|
4
|
+
# Translates raw WSLC error output into friendlier hints.
|
|
5
|
+
class ErrorInterpreter
|
|
6
|
+
def initialize(architecture: Environment.new.architecture)
|
|
7
|
+
@architecture = architecture
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def interpret(output)
|
|
11
|
+
case output
|
|
12
|
+
when /pull access denied|insufficient_scope|authorization failed/i then registry_message
|
|
13
|
+
when %r{no matching manifest for linux/(?:amd64|arm64)}i then architecture_message
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def registry_message
|
|
20
|
+
<<~TEXT
|
|
21
|
+
The container registry rejected the request.
|
|
22
|
+
|
|
23
|
+
Try logging in with:
|
|
24
|
+
|
|
25
|
+
wslc registry login -u <username> docker.io
|
|
26
|
+
TEXT
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def architecture_message
|
|
30
|
+
<<~TEXT
|
|
31
|
+
The image does not contain a manifest for the current CPU architecture.
|
|
32
|
+
|
|
33
|
+
Current architecture:
|
|
34
|
+
#{@architecture}
|
|
35
|
+
|
|
36
|
+
Inspect the image with:
|
|
37
|
+
|
|
38
|
+
docker buildx imagetools inspect <image>
|
|
39
|
+
|
|
40
|
+
Rebuild and push a multi-platform image with:
|
|
41
|
+
|
|
42
|
+
docker buildx build \\
|
|
43
|
+
--platform linux/amd64,linux/arm64 \\
|
|
44
|
+
-t <image> \\
|
|
45
|
+
--push .
|
|
46
|
+
TEXT
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
data/lib/wip/errors.rb
ADDED
data/lib/wip/version.rb
ADDED
data/lib/wip.rb
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'wip/version'
|
|
4
|
+
require_relative 'wip/errors'
|
|
5
|
+
require_relative 'wip/config'
|
|
6
|
+
require_relative 'wip/config_loader'
|
|
7
|
+
require_relative 'wip/environment'
|
|
8
|
+
require_relative 'wip/command_resolver'
|
|
9
|
+
require_relative 'wip/error_interpreter'
|
|
10
|
+
require_relative 'wip/command_builder'
|
|
11
|
+
require_relative 'wip/command_runner'
|
|
12
|
+
require_relative 'wip/doctor'
|
|
13
|
+
require_relative 'wip/cli'
|
metadata
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: wslc-wip
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.1
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Wip contributors
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: thor
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '1.3'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '1.3'
|
|
26
|
+
description: Wip provides project commands and diagnostics for WSLC development environments.
|
|
27
|
+
executables:
|
|
28
|
+
- wip
|
|
29
|
+
extensions: []
|
|
30
|
+
extra_rdoc_files: []
|
|
31
|
+
files:
|
|
32
|
+
- LICENSE
|
|
33
|
+
- README.md
|
|
34
|
+
- exe/wip
|
|
35
|
+
- lib/wip.rb
|
|
36
|
+
- lib/wip/cli.rb
|
|
37
|
+
- lib/wip/command_builder.rb
|
|
38
|
+
- lib/wip/command_resolver.rb
|
|
39
|
+
- lib/wip/command_runner.rb
|
|
40
|
+
- lib/wip/config.rb
|
|
41
|
+
- lib/wip/config_loader.rb
|
|
42
|
+
- lib/wip/doctor.rb
|
|
43
|
+
- lib/wip/environment.rb
|
|
44
|
+
- lib/wip/error_interpreter.rb
|
|
45
|
+
- lib/wip/errors.rb
|
|
46
|
+
- lib/wip/version.rb
|
|
47
|
+
homepage: https://github.com/example/wip
|
|
48
|
+
licenses:
|
|
49
|
+
- MIT
|
|
50
|
+
metadata:
|
|
51
|
+
rubygems_mfa_required: 'true'
|
|
52
|
+
rdoc_options: []
|
|
53
|
+
require_paths:
|
|
54
|
+
- lib
|
|
55
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
56
|
+
requirements:
|
|
57
|
+
- - ">="
|
|
58
|
+
- !ruby/object:Gem::Version
|
|
59
|
+
version: '3.2'
|
|
60
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
61
|
+
requirements:
|
|
62
|
+
- - ">="
|
|
63
|
+
- !ruby/object:Gem::Version
|
|
64
|
+
version: '0'
|
|
65
|
+
requirements: []
|
|
66
|
+
rubygems_version: 3.6.7
|
|
67
|
+
specification_version: 4
|
|
68
|
+
summary: A developer-friendly CLI wrapper for Microsoft WSLC
|
|
69
|
+
test_files: []
|