docker2wslc 0.1.0__tar.gz

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.
@@ -0,0 +1,14 @@
1
+ node_modules/
2
+ .venv/
3
+ venv/
4
+ __pycache__/
5
+ *.py[cod]
6
+ *.egg-info/
7
+ build/
8
+ dist/
9
+ .pytest_cache/
10
+ .vscode-test/
11
+ *.vsix
12
+ .DS_Store
13
+ *.log
14
+ .env
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 WSL Containers
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.
@@ -0,0 +1,161 @@
1
+ Metadata-Version: 2.4
2
+ Name: docker2wslc
3
+ Version: 0.1.0
4
+ Summary: Translate Docker commands and Compose files to wslc, the native WSL container runtime
5
+ Project-URL: Homepage, https://wslcontainers.com
6
+ Project-URL: Documentation, https://wslcontainers.com/reference/cheatsheet/
7
+ Project-URL: Repository, https://github.com/ylwl1997/docker2wslc
8
+ Project-URL: Issues, https://github.com/ylwl1997/docker2wslc/issues
9
+ Author: WSL Containers
10
+ License: MIT
11
+ License-File: LICENSE
12
+ Keywords: compose,containers,devcontainer,docker,windows,wsl,wslc
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Environment :: Console
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: Microsoft :: Windows
18
+ Classifier: Operating System :: OS Independent
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Programming Language :: Python :: 3.9
21
+ Classifier: Programming Language :: Python :: 3.10
22
+ Classifier: Programming Language :: Python :: 3.11
23
+ Classifier: Programming Language :: Python :: 3.12
24
+ Classifier: Programming Language :: Python :: 3.13
25
+ Classifier: Topic :: Software Development :: Build Tools
26
+ Classifier: Topic :: System :: Systems Administration
27
+ Requires-Python: >=3.9
28
+ Provides-Extra: yaml
29
+ Requires-Dist: pyyaml>=6.0; extra == 'yaml'
30
+ Description-Content-Type: text/markdown
31
+
32
+ # docker2wslc
33
+
34
+ Translate Docker commands, Compose files and dev container configs to
35
+ [**wslc**](https://wslcontainers.com) — the native Linux container runtime built into the
36
+ Windows Subsystem for Linux, which runs containers on Windows 11 without Docker Desktop.
37
+
38
+ No network calls, no daemon, no telemetry. Pure rule-driven translation.
39
+
40
+ ```bash
41
+ pip install docker2wslc # translation only
42
+ pip install 'docker2wslc[yaml]' # + Compose analysis
43
+ ```
44
+
45
+ ## Convert a command
46
+
47
+ ```console
48
+ $ docker2wslc convert docker run --gpus all --restart always -p 8080:80 nginx
49
+ wslc run --device nvidia.com/gpu=all -p 8080:80 nginx
50
+
51
+ Migration notes
52
+ WARN Restart policies are not implemented in the wslc preview. Flag dropped — use a
53
+ Windows scheduled task or a wrapper script for auto-restart.
54
+ INFO GPU access in wslc goes through the Container Device Interface. Use
55
+ `--device nvidia.com/gpu=all` instead of `--gpus`.
56
+ ```
57
+
58
+ Commands come from arguments, a file, or stdin:
59
+
60
+ ```bash
61
+ docker2wslc convert docker ps -a
62
+ docker2wslc convert --file deploy.sh
63
+ cat deploy.sh | docker2wslc convert
64
+ ```
65
+
66
+ ## Analyse a Compose file
67
+
68
+ wslc has **no Compose runtime**. This turns each service into an equivalent `wslc run`
69
+ and tells you exactly what cannot be carried over:
70
+
71
+ ```console
72
+ $ docker2wslc compose docker-compose.yml
73
+ # wslc has no Compose runtime. Equivalent commands:
74
+
75
+ wslc volume create pgdata
76
+ wslc network create backend
77
+
78
+ # service: web
79
+ wslc run -d --name web -e NGINX_HOST=localhost -p 8080:80 --network backend nginx:alpine
80
+ x depends_on: No dependency ordering. Start services in order yourself and add readiness waits.
81
+ x restart: No restart policies in the wslc preview.
82
+
83
+ # service: db
84
+ wslc run -d --name db -v pgdata:/var/lib/postgresql/data postgres:16-alpine
85
+ x healthcheck: wslc has no healthcheck support; depends_on conditions relying on it cannot work.
86
+ ```
87
+
88
+ ## Lint a repository
89
+
90
+ Scans for `docker-compose.y*ml`, `compose.y*ml` and `devcontainer.json`:
91
+
92
+ ```bash
93
+ docker2wslc lint .
94
+ ```
95
+
96
+ ## Use in CI
97
+
98
+ Exit codes are meaningful, so this works as a gate:
99
+
100
+ | Code | Meaning |
101
+ |------|---------|
102
+ | `0` | Fully compatible |
103
+ | `1` | Degraded — flags dropped or rewritten, still runnable |
104
+ | `2` | Unmigratable — Compose, Swarm, buildx, or a parse failure |
105
+
106
+ ```yaml
107
+ - run: pip install 'docker2wslc[yaml]'
108
+ - run: docker2wslc lint . # fails the job on exit 2
109
+ ```
110
+
111
+ ## Python API
112
+
113
+ ```python
114
+ from docker2wslc import translate, analyse
115
+
116
+ result = translate("docker run --platform linux/amd64 -it ubuntu bash")
117
+ print(result.output) # wslc run -it ubuntu bash
118
+ print(result.exit_code) # 1
119
+ for note in result.notes:
120
+ print(note.severity, note.text)
121
+
122
+ report = analyse(open("docker-compose.yml").read())
123
+ print(report.as_dict())
124
+ ```
125
+
126
+ `--json` on any subcommand gives the same structure for shell pipelines.
127
+
128
+ ## What wslc cannot do
129
+
130
+ Worth knowing before you migrate. These are runtime limitations, not gaps in this tool:
131
+
132
+ - **No Compose runtime** — translate services by hand, see the [migration guide](https://wslcontainers.com/guides/compose/)
133
+ - **No restart policies** — use a scheduled task
134
+ - **No `--platform`** — host architecture only
135
+ - **No healthchecks** — so `depends_on` conditions cannot work
136
+ - **No Docker socket or Engine API** — [Testcontainers, Portainer and act cannot attach](https://wslcontainers.com/guides/testcontainers/)
137
+ - **No buildx / bake** — single-platform `wslc build` only
138
+ - **GPU via CDI** — `--device nvidia.com/gpu=all`, not `--gpus all`
139
+
140
+ CLI-driven tooling ports to wslc. API-driven tooling does not. That single distinction
141
+ explains most migration surprises — including why
142
+ [VS Code Dev Containers *does* work](https://wslcontainers.com/guides/vscode-dev-containers/)
143
+ once you set `dev.containers.dockerPath` to `wslc`.
144
+
145
+ ## Docs
146
+
147
+ - Interactive converter — <https://wslcontainers.com>
148
+ - Command cheat sheet — <https://wslcontainers.com/reference/cheatsheet/>
149
+ - Install guide — <https://wslcontainers.com/guides/install/>
150
+ - wslc vs Docker Desktop — <https://wslcontainers.com/guides/vs-docker-desktop/>
151
+
152
+ ## Accuracy
153
+
154
+ Rules target the **2026-07 wslc public preview** and live in a single
155
+ [`rules.json`](https://github.com/ylwl1997/docker2wslc/blob/main/rules.json) shared by the
156
+ Python package, the npm CLI, the MCP server and the VS Code extension. wslc is a moving
157
+ target; if you hit a mapping that is wrong,
158
+ [open an issue](https://github.com/ylwl1997/docker2wslc/issues) with the command and the
159
+ actual `wslc` output.
160
+
161
+ MIT licensed.
@@ -0,0 +1,130 @@
1
+ # docker2wslc
2
+
3
+ Translate Docker commands, Compose files and dev container configs to
4
+ [**wslc**](https://wslcontainers.com) — the native Linux container runtime built into the
5
+ Windows Subsystem for Linux, which runs containers on Windows 11 without Docker Desktop.
6
+
7
+ No network calls, no daemon, no telemetry. Pure rule-driven translation.
8
+
9
+ ```bash
10
+ pip install docker2wslc # translation only
11
+ pip install 'docker2wslc[yaml]' # + Compose analysis
12
+ ```
13
+
14
+ ## Convert a command
15
+
16
+ ```console
17
+ $ docker2wslc convert docker run --gpus all --restart always -p 8080:80 nginx
18
+ wslc run --device nvidia.com/gpu=all -p 8080:80 nginx
19
+
20
+ Migration notes
21
+ WARN Restart policies are not implemented in the wslc preview. Flag dropped — use a
22
+ Windows scheduled task or a wrapper script for auto-restart.
23
+ INFO GPU access in wslc goes through the Container Device Interface. Use
24
+ `--device nvidia.com/gpu=all` instead of `--gpus`.
25
+ ```
26
+
27
+ Commands come from arguments, a file, or stdin:
28
+
29
+ ```bash
30
+ docker2wslc convert docker ps -a
31
+ docker2wslc convert --file deploy.sh
32
+ cat deploy.sh | docker2wslc convert
33
+ ```
34
+
35
+ ## Analyse a Compose file
36
+
37
+ wslc has **no Compose runtime**. This turns each service into an equivalent `wslc run`
38
+ and tells you exactly what cannot be carried over:
39
+
40
+ ```console
41
+ $ docker2wslc compose docker-compose.yml
42
+ # wslc has no Compose runtime. Equivalent commands:
43
+
44
+ wslc volume create pgdata
45
+ wslc network create backend
46
+
47
+ # service: web
48
+ wslc run -d --name web -e NGINX_HOST=localhost -p 8080:80 --network backend nginx:alpine
49
+ x depends_on: No dependency ordering. Start services in order yourself and add readiness waits.
50
+ x restart: No restart policies in the wslc preview.
51
+
52
+ # service: db
53
+ wslc run -d --name db -v pgdata:/var/lib/postgresql/data postgres:16-alpine
54
+ x healthcheck: wslc has no healthcheck support; depends_on conditions relying on it cannot work.
55
+ ```
56
+
57
+ ## Lint a repository
58
+
59
+ Scans for `docker-compose.y*ml`, `compose.y*ml` and `devcontainer.json`:
60
+
61
+ ```bash
62
+ docker2wslc lint .
63
+ ```
64
+
65
+ ## Use in CI
66
+
67
+ Exit codes are meaningful, so this works as a gate:
68
+
69
+ | Code | Meaning |
70
+ |------|---------|
71
+ | `0` | Fully compatible |
72
+ | `1` | Degraded — flags dropped or rewritten, still runnable |
73
+ | `2` | Unmigratable — Compose, Swarm, buildx, or a parse failure |
74
+
75
+ ```yaml
76
+ - run: pip install 'docker2wslc[yaml]'
77
+ - run: docker2wslc lint . # fails the job on exit 2
78
+ ```
79
+
80
+ ## Python API
81
+
82
+ ```python
83
+ from docker2wslc import translate, analyse
84
+
85
+ result = translate("docker run --platform linux/amd64 -it ubuntu bash")
86
+ print(result.output) # wslc run -it ubuntu bash
87
+ print(result.exit_code) # 1
88
+ for note in result.notes:
89
+ print(note.severity, note.text)
90
+
91
+ report = analyse(open("docker-compose.yml").read())
92
+ print(report.as_dict())
93
+ ```
94
+
95
+ `--json` on any subcommand gives the same structure for shell pipelines.
96
+
97
+ ## What wslc cannot do
98
+
99
+ Worth knowing before you migrate. These are runtime limitations, not gaps in this tool:
100
+
101
+ - **No Compose runtime** — translate services by hand, see the [migration guide](https://wslcontainers.com/guides/compose/)
102
+ - **No restart policies** — use a scheduled task
103
+ - **No `--platform`** — host architecture only
104
+ - **No healthchecks** — so `depends_on` conditions cannot work
105
+ - **No Docker socket or Engine API** — [Testcontainers, Portainer and act cannot attach](https://wslcontainers.com/guides/testcontainers/)
106
+ - **No buildx / bake** — single-platform `wslc build` only
107
+ - **GPU via CDI** — `--device nvidia.com/gpu=all`, not `--gpus all`
108
+
109
+ CLI-driven tooling ports to wslc. API-driven tooling does not. That single distinction
110
+ explains most migration surprises — including why
111
+ [VS Code Dev Containers *does* work](https://wslcontainers.com/guides/vscode-dev-containers/)
112
+ once you set `dev.containers.dockerPath` to `wslc`.
113
+
114
+ ## Docs
115
+
116
+ - Interactive converter — <https://wslcontainers.com>
117
+ - Command cheat sheet — <https://wslcontainers.com/reference/cheatsheet/>
118
+ - Install guide — <https://wslcontainers.com/guides/install/>
119
+ - wslc vs Docker Desktop — <https://wslcontainers.com/guides/vs-docker-desktop/>
120
+
121
+ ## Accuracy
122
+
123
+ Rules target the **2026-07 wslc public preview** and live in a single
124
+ [`rules.json`](https://github.com/ylwl1997/docker2wslc/blob/main/rules.json) shared by the
125
+ Python package, the npm CLI, the MCP server and the VS Code extension. wslc is a moving
126
+ target; if you hit a mapping that is wrong,
127
+ [open an issue](https://github.com/ylwl1997/docker2wslc/issues) with the command and the
128
+ actual `wslc` output.
129
+
130
+ MIT licensed.
@@ -0,0 +1,48 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.21"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "docker2wslc"
7
+ version = "0.1.0"
8
+ description = "Translate Docker commands and Compose files to wslc, the native WSL container runtime"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "WSL Containers" }]
13
+ keywords = ["docker", "wsl", "wslc", "containers", "windows", "compose", "devcontainer"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Environment :: Console",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Operating System :: Microsoft :: Windows",
20
+ "Operating System :: OS Independent",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.9",
23
+ "Programming Language :: Python :: 3.10",
24
+ "Programming Language :: Python :: 3.11",
25
+ "Programming Language :: Python :: 3.12",
26
+ "Programming Language :: Python :: 3.13",
27
+ "Topic :: Software Development :: Build Tools",
28
+ "Topic :: System :: Systems Administration",
29
+ ]
30
+ dependencies = []
31
+
32
+ [project.optional-dependencies]
33
+ yaml = ["PyYAML>=6.0"]
34
+
35
+ [project.urls]
36
+ Homepage = "https://wslcontainers.com"
37
+ Documentation = "https://wslcontainers.com/reference/cheatsheet/"
38
+ Repository = "https://github.com/ylwl1997/docker2wslc"
39
+ Issues = "https://github.com/ylwl1997/docker2wslc/issues"
40
+
41
+ [project.scripts]
42
+ docker2wslc = "docker2wslc.cli:main"
43
+
44
+ [tool.hatch.build.targets.wheel]
45
+ packages = ["src/docker2wslc"]
46
+
47
+ [tool.hatch.build.targets.sdist]
48
+ include = ["src/", "README.md", "LICENSE"]
@@ -0,0 +1,21 @@
1
+ """docker2wslc — translate Docker commands and Compose files to wslc.
2
+
3
+ wslc is the native Linux container runtime in the Windows Subsystem for Linux.
4
+ Docs and an interactive converter: https://wslcontainers.com
5
+ """
6
+
7
+ __version__ = "0.1.0"
8
+
9
+ from .compose import ComposeReport, analyse
10
+ from .translate import Note, Result, load_rules, translate, translate_line
11
+
12
+ __all__ = [
13
+ "__version__",
14
+ "translate",
15
+ "translate_line",
16
+ "analyse",
17
+ "load_rules",
18
+ "Result",
19
+ "Note",
20
+ "ComposeReport",
21
+ ]
@@ -0,0 +1,233 @@
1
+ """docker2wslc command line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from . import __version__
11
+ from .compose import analyse
12
+ from .translate import RULES, translate
13
+
14
+ SITE = RULES["links"]["site"]
15
+
16
+ C = {
17
+ "reset": "\033[0m", "dim": "\033[2m", "bold": "\033[1m",
18
+ "red": "\033[31m", "yellow": "\033[33m", "cyan": "\033[36m", "green": "\033[32m",
19
+ }
20
+
21
+
22
+ def _paint(enabled: bool):
23
+ if enabled:
24
+ return lambda s, c: f"{C[c]}{s}{C['reset']}"
25
+ return lambda s, c: s
26
+
27
+
28
+ def _read(source: str | None) -> str:
29
+ if source in (None, "-"):
30
+ return sys.stdin.read()
31
+ return Path(source).read_text(encoding="utf-8")
32
+
33
+
34
+ SEV_COLOR = {"error": "red", "warn": "yellow", "info": "cyan"}
35
+
36
+
37
+ def cmd_convert(args: argparse.Namespace) -> int:
38
+ text = " ".join(args.command) if args.command else _read(args.file)
39
+ res = translate(text)
40
+ if args.json:
41
+ print(json.dumps(res.as_dict(), indent=2))
42
+ return res.exit_code
43
+
44
+ paint = _paint(not args.no_color and sys.stdout.isatty())
45
+ print(res.output)
46
+ if res.notes and not args.quiet:
47
+ print(file=sys.stderr)
48
+ print(paint("Migration notes", "bold"), file=sys.stderr)
49
+ for note in res.notes:
50
+ tag = paint(note.severity.upper().ljust(5), SEV_COLOR.get(note.severity, "dim"))
51
+ print(f" {tag} {note.text}", file=sys.stderr)
52
+ if res.compose_hit:
53
+ print(f"\n See {RULES['links']['compose']}", file=sys.stderr)
54
+ return res.exit_code
55
+
56
+
57
+ def cmd_compose(args: argparse.Namespace) -> int:
58
+ report = analyse(_read(args.file))
59
+ if args.json:
60
+ print(json.dumps(report.as_dict(), indent=2))
61
+ return report.exit_code
62
+
63
+ paint = _paint(not args.no_color and sys.stdout.isatty())
64
+ if report.parse_error:
65
+ print(paint(f"error: {report.parse_error}", "red"), file=sys.stderr)
66
+ return report.exit_code
67
+
68
+ print(paint("# wslc has no Compose runtime. Equivalent commands:", "dim"))
69
+ if report.prelude:
70
+ print()
71
+ for line in report.prelude:
72
+ print(line)
73
+ for svc in report.services:
74
+ print()
75
+ print(paint(f"# service: {svc.name}", "bold"))
76
+ print(svc.command)
77
+ for key, note in svc.partial:
78
+ print(paint(f" ! {key}: {note}", "yellow"), file=sys.stderr)
79
+ for key, note in svc.missing:
80
+ print(paint(f" x {key}: {note}", "red"), file=sys.stderr)
81
+ if svc.unknown:
82
+ print(paint(f" ? unrecognised keys: {', '.join(svc.unknown)}", "dim"), file=sys.stderr)
83
+ print(f"\n{paint('Guide:', 'dim')} {RULES['links']['compose']}", file=sys.stderr)
84
+ return report.exit_code
85
+
86
+
87
+ LINT_TARGETS = ("docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml")
88
+
89
+
90
+ def cmd_lint(args: argparse.Namespace) -> int:
91
+ root = Path(args.path)
92
+ paint = _paint(not args.no_color and sys.stdout.isatty())
93
+ worst = 0
94
+ found = False
95
+
96
+ if root.is_file():
97
+ candidates = [root]
98
+ else:
99
+ # rglob("*") does not descend into dot-directories, so the standard
100
+ # .devcontainer/devcontainer.json location must be globbed explicitly.
101
+ patterns = [f"**/{name}" for name in LINT_TARGETS]
102
+ patterns += ["**/devcontainer.json", ".devcontainer/**/devcontainer.json"]
103
+ seen: set[Path] = set()
104
+ candidates = []
105
+ for pattern in patterns:
106
+ for path in root.glob(pattern):
107
+ if not path.is_file():
108
+ continue
109
+ if "node_modules" in path.parts or ".git" in path.parts:
110
+ continue
111
+ resolved = path.resolve()
112
+ if resolved in seen:
113
+ continue
114
+ seen.add(resolved)
115
+ candidates.append(path)
116
+ candidates.sort()
117
+
118
+ for path in candidates:
119
+ found = True
120
+ rel = path.relative_to(root) if root.is_dir() else path
121
+ if path.name == "devcontainer.json":
122
+ code = _lint_devcontainer(path, rel, paint)
123
+ else:
124
+ report = analyse(path.read_text(encoding="utf-8"))
125
+ code = report.exit_code
126
+ issues = [(k, n, "x") for s in report.services for k, n in s.missing]
127
+ issues += [(k, n, "!") for s in report.services for k, n in s.partial]
128
+ if issues:
129
+ print(paint(str(rel), "bold"))
130
+ for key, note, mark in issues:
131
+ col = "red" if mark == "x" else "yellow"
132
+ print(paint(f" {mark} {key}: {note}", col))
133
+ worst = max(worst, code)
134
+
135
+ if not found:
136
+ print("No compose or devcontainer files found.", file=sys.stderr)
137
+ return 0
138
+ if worst == 0:
139
+ print(paint("All checked files are wslc-compatible.", "green"))
140
+ return worst
141
+
142
+
143
+ def _lint_devcontainer(path: Path, rel: Path, paint) -> int:
144
+ keys = RULES["devcontainer"]["keys"]
145
+ try:
146
+ raw = path.read_text(encoding="utf-8")
147
+ # devcontainer.json permits // comments
148
+ stripped = "\n".join(
149
+ line for line in raw.splitlines() if not line.strip().startswith("//")
150
+ )
151
+ doc = json.loads(stripped)
152
+ except Exception as exc:
153
+ print(paint(f"{rel}: cannot parse ({exc})", "red"))
154
+ return 2
155
+ code = 0
156
+ header = False
157
+ for key in doc:
158
+ spec = keys.get(key)
159
+ if not spec or spec["status"] == "ok":
160
+ continue
161
+ if not header:
162
+ print(paint(str(rel), "bold"))
163
+ header = True
164
+ if spec["status"] == "missing":
165
+ print(paint(f" x {key}: {spec.get('note','unsupported')}", "red"))
166
+ code = 2
167
+ else:
168
+ print(paint(f" ! {key}: {spec.get('note','differs')}", "yellow"))
169
+ code = max(code, 1)
170
+ return code
171
+
172
+
173
+ def build_parser() -> argparse.ArgumentParser:
174
+ p = argparse.ArgumentParser(
175
+ prog="docker2wslc",
176
+ description=f"Translate Docker commands and Compose files to wslc. Docs: {SITE}",
177
+ epilog="Exit codes: 0 clean, 1 degraded, 2 unmigratable.",
178
+ )
179
+ p.add_argument("--version", action="version", version=f"docker2wslc {__version__}")
180
+ p.add_argument("--no-color", action="store_true", help="disable ANSI colour")
181
+ sub = p.add_subparsers(dest="cmd", required=True)
182
+
183
+ c = sub.add_parser("convert", help="translate a docker command or script")
184
+ c.add_argument("command", nargs="*", help="docker command (or use --file/stdin)")
185
+ c.add_argument("-f", "--file", help="read commands from a file, - for stdin")
186
+ c.add_argument("--json", action="store_true", help="machine-readable output")
187
+ c.add_argument("-q", "--quiet", action="store_true", help="suppress migration notes")
188
+ c.add_argument("--no-color", action="store_true", help=argparse.SUPPRESS)
189
+ c.set_defaults(func=cmd_convert)
190
+
191
+ m = sub.add_parser("compose", help="analyse a compose file for wslc migration")
192
+ m.add_argument("file", nargs="?", default="docker-compose.yml")
193
+ m.add_argument("--json", action="store_true", help="machine-readable output")
194
+ m.add_argument("--no-color", action="store_true", help=argparse.SUPPRESS)
195
+ m.set_defaults(func=cmd_compose)
196
+
197
+ l = sub.add_parser("lint", help="scan a repo for wslc incompatibilities")
198
+ l.add_argument("path", nargs="?", default=".")
199
+ l.add_argument("--no-color", action="store_true", help=argparse.SUPPRESS)
200
+ l.set_defaults(func=cmd_lint)
201
+ return p
202
+
203
+
204
+ def main(argv: list[str] | None = None) -> int:
205
+ raw = list(sys.argv[1:] if argv is None else argv)
206
+
207
+ # `convert docker run --gpus all ...` — everything after the first `docker`/`podman`
208
+ # token belongs to the command being translated, not to argparse. Without this,
209
+ # argparse rejects `--gpus`/`--platform`/`-p` as unrecognised options.
210
+ passthrough: list[str] = []
211
+ for i, tok in enumerate(raw):
212
+ if tok in ("docker", "podman", "sudo"):
213
+ passthrough = raw[i:]
214
+ raw = raw[:i]
215
+ break
216
+
217
+ parser = build_parser()
218
+ args = parser.parse_args(raw)
219
+ if passthrough:
220
+ args.command = passthrough
221
+ try:
222
+ return args.func(args)
223
+ except FileNotFoundError as exc:
224
+ print(f"error: {exc.filename}: no such file", file=sys.stderr)
225
+ return 2
226
+ except BrokenPipeError: # pragma: no cover
227
+ return 0
228
+ except KeyboardInterrupt: # pragma: no cover
229
+ return 130
230
+
231
+
232
+ if __name__ == "__main__": # pragma: no cover
233
+ sys.exit(main())
@@ -0,0 +1,167 @@
1
+ """Compose migration analyser: docker-compose.yml -> wslc run equivalents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+ from .translate import RULES
9
+
10
+ COMPOSE_KEYS: dict[str, dict[str, Any]] = RULES["compose"]["keys"]
11
+
12
+
13
+ @dataclass
14
+ class ServiceReport:
15
+ name: str
16
+ command: str = ""
17
+ ok: list[str] = field(default_factory=list)
18
+ partial: list[tuple[str, str]] = field(default_factory=list)
19
+ missing: list[tuple[str, str]] = field(default_factory=list)
20
+ unknown: list[str] = field(default_factory=list)
21
+
22
+
23
+ @dataclass
24
+ class ComposeReport:
25
+ services: list[ServiceReport] = field(default_factory=list)
26
+ prelude: list[str] = field(default_factory=list)
27
+ parse_error: str | None = None
28
+
29
+ @property
30
+ def exit_code(self) -> int:
31
+ if self.parse_error:
32
+ return 2
33
+ if any(s.missing for s in self.services):
34
+ return 2
35
+ if any(s.partial for s in self.services):
36
+ return 1
37
+ return 0
38
+
39
+ def as_dict(self) -> dict[str, Any]:
40
+ return {
41
+ "parseError": self.parse_error,
42
+ "prelude": self.prelude,
43
+ "services": [
44
+ {
45
+ "name": s.name,
46
+ "command": s.command,
47
+ "ok": s.ok,
48
+ "partial": [{"key": k, "note": n} for k, n in s.partial],
49
+ "missing": [{"key": k, "note": n} for k, n in s.missing],
50
+ "unknown": s.unknown,
51
+ }
52
+ for s in self.services
53
+ ],
54
+ "exitCode": self.exit_code,
55
+ }
56
+
57
+
58
+ def _as_list(value: Any) -> list[str]:
59
+ if value is None:
60
+ return []
61
+ if isinstance(value, list):
62
+ return [str(v) for v in value]
63
+ if isinstance(value, dict):
64
+ return [f"{k}={v}" for k, v in value.items()]
65
+ return [str(value)]
66
+
67
+
68
+ def _build_run(name: str, svc: dict[str, Any]) -> str:
69
+ args: list[str] = ["wslc", "run", "-d", "--name", svc.get("container_name") or name]
70
+
71
+ for env in _as_list(svc.get("environment")):
72
+ args += ["-e", env]
73
+ for env_file in _as_list(svc.get("env_file")):
74
+ args += ["--env-file", env_file]
75
+ for port in _as_list(svc.get("ports")):
76
+ args += ["-p", port.strip('"')]
77
+ for vol in _as_list(svc.get("volumes")):
78
+ args += ["-v", vol]
79
+ for net in _as_list(svc.get("networks")):
80
+ args += ["--network", net]
81
+ for cap in _as_list(svc.get("cap_add")):
82
+ args += ["--cap-add", cap]
83
+ for dev in _as_list(svc.get("devices")):
84
+ args += ["--device", dev]
85
+ for label in _as_list(svc.get("labels")):
86
+ args += ["--label", label]
87
+ for tmp in _as_list(svc.get("tmpfs")):
88
+ args += ["--tmpfs", tmp]
89
+
90
+ if svc.get("working_dir"):
91
+ args += ["-w", str(svc["working_dir"])]
92
+ if svc.get("user"):
93
+ args += ["-u", str(svc["user"])]
94
+ if svc.get("hostname"):
95
+ args += ["--hostname", str(svc["hostname"])]
96
+ if svc.get("entrypoint"):
97
+ ep = svc["entrypoint"]
98
+ args += ["--entrypoint", ep if isinstance(ep, str) else " ".join(ep)]
99
+ if svc.get("stdin_open"):
100
+ args.append("-i")
101
+ if svc.get("tty"):
102
+ args.append("-t")
103
+
104
+ image = svc.get("image")
105
+ if not image and svc.get("build"):
106
+ image = f"{name}:local"
107
+ args.append(str(image or "<image>"))
108
+
109
+ cmd = svc.get("command")
110
+ if cmd:
111
+ args += cmd.split() if isinstance(cmd, str) else [str(c) for c in cmd]
112
+
113
+ return " ".join(args)
114
+
115
+
116
+ def analyse(text: str) -> ComposeReport:
117
+ """Analyse a compose file. Requires PyYAML; reports cleanly if absent."""
118
+ report = ComposeReport()
119
+ try:
120
+ import yaml
121
+ except ImportError: # pragma: no cover
122
+ report.parse_error = "PyYAML is required: pip install 'docker2wslc[yaml]'"
123
+ return report
124
+
125
+ try:
126
+ doc = yaml.safe_load(text) or {}
127
+ except Exception as exc:
128
+ report.parse_error = f"YAML parse error: {exc}"
129
+ return report
130
+
131
+ if not isinstance(doc, dict):
132
+ report.parse_error = "Compose file did not parse to a mapping."
133
+ return report
134
+
135
+ for vol in (doc.get("volumes") or {}):
136
+ report.prelude.append(f"wslc volume create {vol}")
137
+ for net in (doc.get("networks") or {}):
138
+ report.prelude.append(f"wslc network create {net}")
139
+
140
+ services = doc.get("services") or {}
141
+ if not isinstance(services, dict):
142
+ report.parse_error = "`services` is not a mapping."
143
+ return report
144
+
145
+ for name, svc in services.items():
146
+ if not isinstance(svc, dict):
147
+ continue
148
+ sr = ServiceReport(name=str(name))
149
+ for key in svc:
150
+ spec = COMPOSE_KEYS.get(str(key))
151
+ if spec is None:
152
+ sr.unknown.append(str(key))
153
+ elif spec["status"] == "ok":
154
+ sr.ok.append(str(key))
155
+ elif spec["status"] == "partial":
156
+ sr.partial.append((str(key), spec.get("note", "")))
157
+ else:
158
+ sr.missing.append((str(key), spec.get("note", "")))
159
+ sr.command = _build_run(str(name), svc)
160
+ report.services.append(sr)
161
+
162
+ # depends_on ordering hint
163
+ ordered = [s.name for s in report.services]
164
+ if any("depends_on" in dict(s.missing) for s in report.services):
165
+ report.prelude.append(f"# start order matters: {' -> '.join(ordered)}")
166
+
167
+ return report
@@ -0,0 +1,167 @@
1
+ {
2
+ "$comment": "Single source of truth for Docker -> wslc translation. Consumed by the Python package, the npm CLI, the MCP server and the VS Code extension. Do not fork this logic per language.",
3
+ "schemaVersion": 1,
4
+ "wslcPreview": "2026-07",
5
+ "identical": [
6
+ "run", "build", "pull", "push", "stop", "start", "restart", "exec", "logs",
7
+ "inspect", "stats", "cp", "tag", "login", "logout", "kill", "pause",
8
+ "unpause", "version", "info", "top", "wait", "port", "rename", "diff",
9
+ "commit", "save", "load", "attach"
10
+ ],
11
+ "renamed": {
12
+ "ps": "container list",
13
+ "images": "image list",
14
+ "rmi": "image remove",
15
+ "rm": "container remove",
16
+ "create": "container create",
17
+ "history": "image history",
18
+ "search": "image search"
19
+ },
20
+ "renamedFlags": {
21
+ "ps": { "-a": "--all", "--all": "--all", "-q": "--quiet", "--quiet": "--quiet" },
22
+ "images": { "-a": "--all", "--all": "--all" },
23
+ "rm": { "-f": "--force", "--force": "--force" },
24
+ "rmi": { "-f": "--force", "--force": "--force" }
25
+ },
26
+ "unsupported": {
27
+ "swarm": "Swarm orchestration has no wslc equivalent. Use Kubernetes or keep Docker for this workload.",
28
+ "service": "Swarm services are not supported by wslc.",
29
+ "stack": "Swarm stacks are not supported by wslc.",
30
+ "node": "Swarm node management is not supported by wslc.",
31
+ "secret": "Swarm secrets are not supported. Pass values with -e or mount a file.",
32
+ "config": "Swarm configs are not supported by wslc.",
33
+ "plugin": "Docker plugins are not supported by wslc.",
34
+ "context": "wslc has a single local runtime, so there are no contexts to switch.",
35
+ "buildx": "buildx / bake is not available. Use wslc build for single-platform images.",
36
+ "manifest": "Manifest lists cannot be created with wslc in the preview.",
37
+ "trust": "Docker Content Trust is Docker-specific and has no wslc equivalent.",
38
+ "checkpoint": "Checkpoint/restore is not implemented in wslc."
39
+ },
40
+ "flags": {
41
+ "--platform": {
42
+ "action": "drop",
43
+ "takesValue": true,
44
+ "severity": "warn",
45
+ "note": "`--platform` is dropped: wslc builds and runs for the host architecture only in the preview."
46
+ },
47
+ "--restart": {
48
+ "action": "drop",
49
+ "takesValue": true,
50
+ "severity": "warn",
51
+ "note": "Restart policies are not implemented in the wslc preview. Flag dropped — use a Windows scheduled task or a wrapper script for auto-restart."
52
+ },
53
+ "--gpus": {
54
+ "action": "rewrite",
55
+ "takesValue": true,
56
+ "replaceWith": "--device nvidia.com/gpu=all",
57
+ "severity": "info",
58
+ "note": "GPU access in wslc goes through the Container Device Interface. Use `--device nvidia.com/gpu=all` instead of `--gpus`."
59
+ },
60
+ "--network": {
61
+ "action": "conditional",
62
+ "takesValue": true,
63
+ "whenValue": {
64
+ "host": {
65
+ "action": "drop",
66
+ "severity": "info",
67
+ "note": "`--network host` behaves differently: wslc routes container traffic through the Windows host stack already, so host networking is implicit. Flag dropped."
68
+ }
69
+ },
70
+ "severity": "info",
71
+ "note": "Custom networks are supported but bridge-only. Create it first with `wslc network create`."
72
+ },
73
+ "--volume": {
74
+ "action": "keep",
75
+ "takesValue": true,
76
+ "aliases": ["-v", "--mount"],
77
+ "severity": "info",
78
+ "noteWhenWindowsPath": "Windows host paths are shared over VirtioFS. Use forward slashes (`C:/work:/app`) and expect Linux-style permissions on the container side."
79
+ }
80
+ },
81
+ "compose": {
82
+ "supported": false,
83
+ "note": "wslc has no built-in Compose runtime. Translate each service into a wslc run call, or drive them from a PowerShell/bash script.",
84
+ "docsUrl": "https://wslcontainers.com/guides/compose/",
85
+ "keys": {
86
+ "image": { "status": "ok", "maps": "positional image argument" },
87
+ "build": { "status": "ok", "maps": "wslc build -t <name>" },
88
+ "command": { "status": "ok", "maps": "trailing command args" },
89
+ "entrypoint": { "status": "ok", "maps": "--entrypoint" },
90
+ "environment": { "status": "ok", "maps": "-e KEY=VALUE" },
91
+ "env_file": { "status": "ok", "maps": "--env-file" },
92
+ "ports": { "status": "ok", "maps": "-p HOST:CONTAINER" },
93
+ "volumes": { "status": "partial", "maps": "-v", "note": "Named volumes must be created first with wslc volume create. Windows paths go over VirtioFS." },
94
+ "networks": { "status": "partial", "maps": "--network", "note": "Bridge-only. Create with wslc network create before running." },
95
+ "working_dir": { "status": "ok", "maps": "-w" },
96
+ "user": { "status": "ok", "maps": "-u" },
97
+ "hostname": { "status": "ok", "maps": "--hostname" },
98
+ "container_name": { "status": "ok", "maps": "--name" },
99
+ "labels": { "status": "ok", "maps": "--label" },
100
+ "cap_add": { "status": "partial", "maps": "--cap-add", "note": "Some capabilities are restricted inside the utility VM." },
101
+ "devices": { "status": "partial", "maps": "--device", "note": "CDI syntax differs from Docker for GPUs." },
102
+ "tmpfs": { "status": "ok", "maps": "--tmpfs" },
103
+ "stdin_open": { "status": "ok", "maps": "-i" },
104
+ "tty": { "status": "ok", "maps": "-t" },
105
+ "restart": { "status": "missing", "note": "No restart policies in the wslc preview." },
106
+ "healthcheck": { "status": "missing", "note": "wslc has no healthcheck support; depends_on conditions relying on it cannot work." },
107
+ "depends_on": { "status": "missing", "note": "No dependency ordering. Start services in order yourself and add readiness waits." },
108
+ "deploy": { "status": "missing", "note": "Swarm-only section, ignored entirely." },
109
+ "profiles": { "status": "missing", "note": "Compose-only concept." },
110
+ "extends": { "status": "missing", "note": "Compose-only concept." },
111
+ "configs": { "status": "missing", "note": "Swarm configs are unsupported; mount a file instead." },
112
+ "secrets": { "status": "missing", "note": "Swarm secrets are unsupported; use -e or a mounted file." }
113
+ }
114
+ },
115
+ "devcontainer": {
116
+ "keys": {
117
+ "image": { "status": "ok" },
118
+ "build.dockerfile": { "status": "ok" },
119
+ "build.args": { "status": "ok", "maps": "--build-arg" },
120
+ "build.platform": { "status": "missing", "note": "Host architecture only." },
121
+ "workspaceMount": { "status": "partial", "note": "VirtioFS; use forward slashes for Windows paths." },
122
+ "workspaceFolder": { "status": "ok" },
123
+ "containerEnv": { "status": "ok", "maps": "-e" },
124
+ "remoteEnv": { "status": "ok" },
125
+ "forwardPorts": { "status": "ok", "maps": "-p" },
126
+ "postCreateCommand": { "status": "ok", "maps": "wslc exec" },
127
+ "postStartCommand": { "status": "ok" },
128
+ "postAttachCommand": { "status": "ok" },
129
+ "remoteUser": { "status": "ok", "maps": "-u" },
130
+ "containerUser": { "status": "ok" },
131
+ "mounts": { "status": "partial", "note": "VirtioFS path rules apply." },
132
+ "runArgs": { "status": "partial", "note": "Passed through verbatim; unsupported flags will fail at runtime." },
133
+ "dockerComposeFile": { "status": "missing", "note": "No Compose runtime. Replace with a single image or build entry." },
134
+ "features": { "status": "missing", "note": "Requires BuildKit metadata that wslc build does not emit." },
135
+ "runServices": { "status": "missing", "note": "Compose-only key." },
136
+ "shutdownAction": { "status": "partial", "note": "stopCompose is meaningless without Compose." }
137
+ },
138
+ "requiredSetting": {
139
+ "key": "dev.containers.dockerPath",
140
+ "value": "wslc",
141
+ "note": "Point the Dev Containers extension at wslc. Prefer workspace scope over global."
142
+ }
143
+ },
144
+ "apiIncompatible": {
145
+ "note": "CLI-driven tooling ports to wslc. API-driven tooling cannot, because wslc is daemonless and exposes no Docker Engine API or socket.",
146
+ "tools": [
147
+ { "name": "Testcontainers", "via": "Engine API", "works": false },
148
+ { "name": "Docker-in-Docker sidecars", "via": "mounted socket", "works": false },
149
+ { "name": "Portainer", "via": "Engine API", "works": false },
150
+ { "name": "Lazydocker", "via": "Engine API", "works": false },
151
+ { "name": "LocalStack (docker mode)", "via": "Engine API", "works": false },
152
+ { "name": "act", "via": "Engine API", "works": false },
153
+ { "name": "VS Code Dev Containers", "via": "CLI binary", "works": true },
154
+ { "name": "shell scripts calling docker", "via": "CLI binary", "works": true }
155
+ ]
156
+ },
157
+ "links": {
158
+ "site": "https://wslcontainers.com",
159
+ "converter": "https://wslcontainers.com/",
160
+ "cheatsheet": "https://wslcontainers.com/reference/cheatsheet/",
161
+ "compose": "https://wslcontainers.com/guides/compose/",
162
+ "install": "https://wslcontainers.com/guides/install/",
163
+ "vsDocker": "https://wslcontainers.com/guides/vs-docker-desktop/",
164
+ "devcontainers": "https://wslcontainers.com/guides/vscode-dev-containers/",
165
+ "testcontainers": "https://wslcontainers.com/guides/testcontainers/"
166
+ }
167
+ }
@@ -0,0 +1,267 @@
1
+ """Docker -> wslc command translation, driven by the shared rules.json."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from dataclasses import dataclass, field
8
+ from importlib.resources import files
9
+ from typing import Any
10
+
11
+ WINDOWS_PATH = re.compile(r"^[A-Za-z]:[\\/]")
12
+
13
+
14
+ def load_rules() -> dict[str, Any]:
15
+ """Load the bundled rules.json (single source of truth across languages)."""
16
+ data = files("docker2wslc").joinpath("rules.json").read_text(encoding="utf-8")
17
+ return json.loads(data)
18
+
19
+
20
+ RULES = load_rules()
21
+
22
+ SEV_ORDER = {"info": 0, "warn": 1, "error": 2}
23
+
24
+
25
+ @dataclass
26
+ class Note:
27
+ severity: str
28
+ text: str
29
+
30
+ def __str__(self) -> str: # pragma: no cover - display helper
31
+ return f"[{self.severity}] {self.text}"
32
+
33
+
34
+ @dataclass
35
+ class Result:
36
+ """Outcome of translating one or more lines."""
37
+
38
+ output: str = ""
39
+ notes: list[Note] = field(default_factory=list)
40
+ compose_hit: bool = False
41
+ unsupported_hit: bool = False
42
+
43
+ @property
44
+ def exit_code(self) -> int:
45
+ """0 clean, 1 degraded (flags dropped/rewritten), 2 unmigratable."""
46
+ if self.unsupported_hit or self.compose_hit:
47
+ return 2
48
+ if any(n.severity in ("warn", "error") for n in self.notes):
49
+ return 1
50
+ return 0
51
+
52
+ def as_dict(self) -> dict[str, Any]:
53
+ return {
54
+ "output": self.output,
55
+ "notes": [{"severity": n.severity, "text": n.text} for n in self.notes],
56
+ "composeHit": self.compose_hit,
57
+ "unsupportedHit": self.unsupported_hit,
58
+ "exitCode": self.exit_code,
59
+ }
60
+
61
+
62
+ def tokenize(line: str) -> list[str]:
63
+ """Split a shell-ish line, keeping quoted spans intact (quotes preserved)."""
64
+ out: list[str] = []
65
+ cur = ""
66
+ quote: str | None = None
67
+ for ch in line:
68
+ if quote:
69
+ cur += ch
70
+ if ch == quote:
71
+ quote = None
72
+ elif ch in ("'", '"'):
73
+ quote = ch
74
+ cur += ch
75
+ elif ch.isspace():
76
+ if cur:
77
+ out.append(cur)
78
+ cur = ""
79
+ else:
80
+ cur += ch
81
+ if cur:
82
+ out.append(cur)
83
+ return out
84
+
85
+
86
+ def _flag_spec(flag: str) -> tuple[str, dict[str, Any]] | tuple[None, None]:
87
+ flags = RULES["flags"]
88
+ if flag in flags:
89
+ return flag, flags[flag]
90
+ for canonical, spec in flags.items():
91
+ if flag in spec.get("aliases", []):
92
+ return canonical, spec
93
+ return None, None
94
+
95
+
96
+ def _translate_args(args: list[str], notes: list[Note]) -> list[str]:
97
+ out: list[str] = []
98
+ i = 0
99
+ while i < len(args):
100
+ arg = args[i]
101
+ bare, _, inline = arg.partition("=")
102
+ canonical, spec = _flag_spec(bare)
103
+
104
+ if spec is None:
105
+ out.append(arg)
106
+ i += 1
107
+ continue
108
+
109
+ takes_value = spec.get("takesValue", False)
110
+ has_inline = "=" in arg
111
+ value = inline if has_inline else (args[i + 1] if i + 1 < len(args) else "")
112
+ consumed = 1 if has_inline or not takes_value else 2
113
+
114
+ action = spec["action"]
115
+
116
+ if action == "conditional":
117
+ branch = spec.get("whenValue", {}).get(value)
118
+ if branch:
119
+ if branch["action"] == "drop":
120
+ notes.append(Note(branch.get("severity", "info"), branch["note"]))
121
+ i += consumed
122
+ continue
123
+ notes.append(Note(spec.get("severity", "info"), spec["note"]))
124
+ out.append(f"{bare}={value}" if has_inline else bare)
125
+ if not has_inline and takes_value and value:
126
+ out.append(value)
127
+ i += consumed
128
+ continue
129
+
130
+ if action == "drop":
131
+ notes.append(Note(spec.get("severity", "warn"), spec["note"]))
132
+ i += consumed
133
+ continue
134
+
135
+ if action == "rewrite":
136
+ notes.append(Note(spec.get("severity", "info"), spec["note"]))
137
+ out.extend(spec["replaceWith"].split())
138
+ i += consumed
139
+ continue
140
+
141
+ # action == "keep"
142
+ note_win = spec.get("noteWhenWindowsPath")
143
+ if note_win and (WINDOWS_PATH.match(value) or value.startswith("/mnt/")):
144
+ notes.append(Note(spec.get("severity", "info"), note_win))
145
+ out.append(arg)
146
+ if not has_inline and takes_value and value:
147
+ out.append(value)
148
+ i += consumed
149
+ return out
150
+
151
+
152
+ def translate_line(raw: str) -> Result | None:
153
+ """Translate a single line. Returns None for blank lines."""
154
+ line = raw.strip()
155
+ if not line:
156
+ return None
157
+ if line.startswith("#"):
158
+ return Result(output=line)
159
+
160
+ notes: list[Note] = []
161
+ tokens = tokenize(line)
162
+
163
+ if tokens and tokens[0] == "sudo":
164
+ tokens.pop(0)
165
+ notes.append(
166
+ Note(
167
+ "info",
168
+ "Dropped sudo — wslc runs as your Windows user, no elevation needed "
169
+ "for normal container operations.",
170
+ )
171
+ )
172
+
173
+ if not tokens or tokens[0] not in ("docker", "podman"):
174
+ return Result(
175
+ output=f"# not a docker command: {line}",
176
+ notes=[Note("info", "Only docker/podman commands are translated. Line left unchanged.")],
177
+ )
178
+
179
+ if tokens[0] == "podman":
180
+ notes.append(
181
+ Note("info", "Treated podman as Docker-compatible; flag coverage is nearly identical.")
182
+ )
183
+ tokens.pop(0)
184
+
185
+ if not tokens:
186
+ return Result(output="wslc", notes=notes)
187
+
188
+ if tokens[0] in ("compose", "docker-compose"):
189
+ compose = RULES["compose"]
190
+ notes.append(Note("error", compose["note"]))
191
+ return Result(
192
+ output="# No native Compose runtime in wslc.", notes=notes, compose_hit=True
193
+ )
194
+
195
+ two = f"{tokens[0]} {tokens[1]}" if len(tokens) > 1 else ""
196
+ if two and two in (RULES["renamed"].values()):
197
+ rest = _translate_args(tokens[2:], notes)
198
+ return Result(output=" ".join(["wslc", two, *rest]).strip(), notes=notes)
199
+
200
+ verb = tokens[0]
201
+
202
+ if verb in RULES["unsupported"]:
203
+ notes.append(Note("error", RULES["unsupported"][verb]))
204
+ return Result(
205
+ output=f"# {verb}: not supported by wslc", notes=notes, unsupported_hit=True
206
+ )
207
+
208
+ if verb in RULES["renamed"]:
209
+ mapped = RULES["renamed"][verb]
210
+ flag_map = RULES["renamedFlags"].get(verb, {})
211
+ rest: list[str] = []
212
+ for arg in tokens[1:]:
213
+ rest.append(flag_map.get(arg, arg))
214
+ rest = _translate_args(rest, notes)
215
+ notes.append(
216
+ Note(
217
+ "info",
218
+ f"`docker {verb}` is grouped under a noun in wslc: `wslc {mapped}`. "
219
+ f"Recent previews accept `wslc {verb}` as an alias, but the noun form is stable.",
220
+ )
221
+ )
222
+ return Result(output=" ".join(["wslc", mapped, *rest]).strip(), notes=notes)
223
+
224
+ if verb in RULES["identical"]:
225
+ rest = _translate_args(tokens[1:], notes)
226
+ return Result(output=" ".join(["wslc", verb, *rest]).strip(), notes=notes)
227
+
228
+ notes.append(
229
+ Note(
230
+ "warn",
231
+ f"Unknown docker subcommand `{verb}` — passed through unchanged. "
232
+ "Verify against `wslc --help`.",
233
+ )
234
+ )
235
+ rest = _translate_args(tokens[1:], notes)
236
+ return Result(output=" ".join(["wslc", verb, *rest]).strip(), notes=notes)
237
+
238
+
239
+ def translate(text: str) -> Result:
240
+ """Translate a multi-line script, de-duplicating notes."""
241
+ lines: list[str] = []
242
+ notes: list[Note] = []
243
+ seen: set[str] = set()
244
+ compose_hit = False
245
+ unsupported_hit = False
246
+
247
+ for raw in str(text).split("\n"):
248
+ res = translate_line(raw)
249
+ if res is None:
250
+ lines.append("")
251
+ continue
252
+ lines.append(res.output)
253
+ compose_hit = compose_hit or res.compose_hit
254
+ unsupported_hit = unsupported_hit or res.unsupported_hit
255
+ for note in res.notes:
256
+ if note.text not in seen:
257
+ seen.add(note.text)
258
+ notes.append(note)
259
+
260
+ joined = re.sub(r"\n{3,}", "\n\n", "\n".join(lines)).strip()
261
+ notes.sort(key=lambda n: -SEV_ORDER.get(n.severity, 0))
262
+ return Result(
263
+ output=joined,
264
+ notes=notes,
265
+ compose_hit=compose_hit,
266
+ unsupported_hit=unsupported_hit,
267
+ )