qem 0.2.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.
Files changed (45) hide show
  1. qem-0.2.0/.editorconfig +14 -0
  2. qem-0.2.0/.gitattributes +5 -0
  3. qem-0.2.0/.github/workflows/build.yml +152 -0
  4. qem-0.2.0/.gitignore +20 -0
  5. qem-0.2.0/.python-version +1 -0
  6. qem-0.2.0/CLAUDE.md +19 -0
  7. qem-0.2.0/LICENSE +12 -0
  8. qem-0.2.0/LICENSES/0BSD.txt +5 -0
  9. qem-0.2.0/PKG-INFO +85 -0
  10. qem-0.2.0/README.md +56 -0
  11. qem-0.2.0/REUSE.toml +12 -0
  12. qem-0.2.0/docs/DESIGN.md +158 -0
  13. qem-0.2.0/docs/DEVELOPMENT.md +110 -0
  14. qem-0.2.0/docs/MANUAL.md +267 -0
  15. qem-0.2.0/docs/protocol.md +188 -0
  16. qem-0.2.0/pyproject.toml +66 -0
  17. qem-0.2.0/src/qem/__init__.py +63 -0
  18. qem-0.2.0/src/qem/__main__.py +7 -0
  19. qem-0.2.0/src/qem/cli/__init__.py +202 -0
  20. qem-0.2.0/src/qem/cli/commands.py +629 -0
  21. qem-0.2.0/src/qem/cli/project_store.py +99 -0
  22. qem-0.2.0/src/qem/config.py +75 -0
  23. qem-0.2.0/src/qem/device_login.py +143 -0
  24. qem-0.2.0/src/qem/errors.py +95 -0
  25. qem-0.2.0/src/qem/mac_session.py +316 -0
  26. qem-0.2.0/src/qem/models.py +197 -0
  27. qem-0.2.0/src/qem/nomac.py +145 -0
  28. qem-0.2.0/src/qem/ssh.py +430 -0
  29. qem-0.2.0/src/qem/transfer.py +97 -0
  30. qem-0.2.0/src/qem/transport.py +79 -0
  31. qem-0.2.0/src/qem/tunnel.py +189 -0
  32. qem-0.2.0/src/qem/upload.py +579 -0
  33. qem-0.2.0/src/qem/version.py +24 -0
  34. qem-0.2.0/src/qem/version_info.py +11 -0
  35. qem-0.2.0/src/qem/vnc.py +207 -0
  36. qem-0.2.0/tests/conftest.py +74 -0
  37. qem-0.2.0/tests/test_cli.py +504 -0
  38. qem-0.2.0/tests/test_device_login.py +235 -0
  39. qem-0.2.0/tests/test_library.py +289 -0
  40. qem-0.2.0/tests/test_ssh_choice.py +133 -0
  41. qem-0.2.0/tests/test_ssh_transfer.py +272 -0
  42. qem-0.2.0/tests/test_tunnel.py +172 -0
  43. qem-0.2.0/tests/test_upload.py +304 -0
  44. qem-0.2.0/tests/test_vnc.py +205 -0
  45. qem-0.2.0/uv.lock +492 -0
@@ -0,0 +1,14 @@
1
+ # SPDX-FileCopyrightText: 2026 René de Hesselle <dehesselle@web.de>
2
+ #
3
+ # SPDX-License-Identifier: 0BSD
4
+
5
+ root = true
6
+
7
+ [*]
8
+ end_of_line = lf
9
+ insert_final_newline = true
10
+ charset = utf-8
11
+ trim_trailing_whitespace = true
12
+
13
+ [*.md]
14
+ trim_trailing_whitespace = false
@@ -0,0 +1,5 @@
1
+ # SPDX-FileCopyrightText: 2026 René de Hesselle <dehesselle@web.de>
2
+ #
3
+ # SPDX-License-Identifier: 0BSD
4
+
5
+ * text=auto eol=lf
@@ -0,0 +1,152 @@
1
+ # SPDX-FileCopyrightText: 2026 René de Hesselle <dehesselle@web.de>
2
+ #
3
+ # SPDX-License-Identifier: 0BSD
4
+
5
+ name: Build
6
+ on:
7
+ push:
8
+ branches: ["**"]
9
+ tags: ["v[0-9]*"]
10
+ pull_request:
11
+
12
+ permissions:
13
+ contents: read
14
+
15
+ jobs:
16
+ #-----------------------------------------------------------------------------
17
+
18
+ Lint:
19
+ runs-on: ubuntu-latest
20
+ steps:
21
+ - name: Checkout repository
22
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
23
+ with:
24
+ persist-credentials: false
25
+
26
+ - name: Install uv
27
+ uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
28
+
29
+ # keep aligned with [tool.ruff] in pyproject.toml
30
+ - name: Check formatting
31
+ run: uvx --from 'ruff~=0.16.0' ruff format --check .
32
+
33
+ - name: Lint
34
+ run: uvx --from 'ruff~=0.16.0' ruff check .
35
+
36
+ - name: Check REUSE compliance
37
+ run: uvx reuse lint
38
+
39
+ #-----------------------------------------------------------------------------
40
+
41
+ Test:
42
+ strategy:
43
+ fail-fast: false
44
+ matrix:
45
+ os: [ubuntu-latest, windows-latest, macos-latest]
46
+ python: ["3.12"]
47
+ include:
48
+ - os: ubuntu-latest
49
+ python: "3.14"
50
+ runs-on: ${{ matrix.os }}
51
+ steps:
52
+ # full history for hatch-vcs
53
+ - name: Checkout repository
54
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
55
+ with:
56
+ fetch-depth: 0
57
+ persist-credentials: false
58
+
59
+ - name: Install uv
60
+ uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
61
+ with:
62
+ python-version: ${{ matrix.python }}
63
+
64
+ - name: Run tests
65
+ run: uv run --locked pytest
66
+
67
+ #-----------------------------------------------------------------------------
68
+
69
+ Build:
70
+ runs-on: ubuntu-latest
71
+ steps:
72
+ - name: Checkout repository
73
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
74
+ with:
75
+ fetch-depth: 0
76
+ persist-credentials: false
77
+
78
+ - name: Install uv
79
+ uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
80
+
81
+ - name: Build project
82
+ run: uv build --no-sources
83
+
84
+ - name: Smoke-test wheel
85
+ run: |
86
+ uv run --isolated --no-project --with dist/*.whl python - <<'EOF'
87
+ import os
88
+ import qem
89
+ print(qem.__version__)
90
+ if os.environ["GITHUB_REF_TYPE"] == "tag":
91
+ expected = os.environ["GITHUB_REF_NAME"].removeprefix("v")
92
+ assert qem.__version__ == expected, (qem.__version__, expected)
93
+ EOF
94
+ uv run --isolated --no-project --with dist/*.whl qem --version
95
+
96
+ - name: Upload artifacts
97
+ uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
98
+ with:
99
+ name: dist
100
+ path: dist/
101
+
102
+ #-----------------------------------------------------------------------------
103
+
104
+ Release:
105
+ runs-on: ubuntu-latest
106
+ permissions:
107
+ contents: write
108
+ needs:
109
+ - Lint
110
+ - Test
111
+ - Build
112
+ if: startsWith(github.ref, 'refs/tags/v')
113
+ steps:
114
+ - name: Download artifacts
115
+ uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
116
+ with:
117
+ name: dist
118
+ path: dist/
119
+
120
+ - name: Create draft release
121
+ env:
122
+ GH_TOKEN: ${{ github.token }}
123
+ run: >-
124
+ gh release create "$GITHUB_REF_NAME" dist/*
125
+ --repo "$GITHUB_REPOSITORY"
126
+ --draft
127
+ --verify-tag
128
+ --generate-notes
129
+
130
+ #-----------------------------------------------------------------------------
131
+
132
+ PyPI:
133
+ runs-on: ubuntu-latest
134
+ permissions:
135
+ id-token: write
136
+ needs:
137
+ - Build
138
+ - Release
139
+ if: startsWith(github.ref, 'refs/tags/v')
140
+ environment:
141
+ name: pypi
142
+ url: https://pypi.org/p/qem
143
+
144
+ steps:
145
+ - name: Download artifacts
146
+ uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
147
+ with:
148
+ name: dist
149
+ path: dist/
150
+
151
+ - name: Publish to PyPI
152
+ uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3
qem-0.2.0/.gitignore ADDED
@@ -0,0 +1,20 @@
1
+ # SPDX-FileCopyrightText: 2026 René de Hesselle <dehesselle@web.de>
2
+ #
3
+ # SPDX-License-Identifier: 0BSD
4
+
5
+ # Python-generated files
6
+ __pycache__/
7
+ *.py[oc]
8
+ build/
9
+ dist/
10
+ wheels/
11
+ *.egg-info
12
+
13
+ # Virtual environments
14
+ .venv
15
+
16
+ # Generated by hatch-vcs
17
+ src/qem/version.py
18
+
19
+ # Local devbox helper
20
+ justfile
@@ -0,0 +1 @@
1
+ 3.12
qem-0.2.0/CLAUDE.md ADDED
@@ -0,0 +1,19 @@
1
+ # qem
2
+
3
+ A Python library and CLI for NoMac cloud Macs that replaces the npm `nomac` CLI for
4
+ sessions, jobs, ssh and sync.
5
+
6
+ - Read docs/DESIGN.md before architectural changes and respect its core principles,
7
+ especially: the library never prints or exits, and the tunnel's stdout carries SSH
8
+ bytes only.
9
+ - Read docs/protocol.md before touching API calls. Update it when the live service turns
10
+ out to differ.
11
+ - docs/MANUAL.md documents every command, option and library call.
12
+
13
+ @docs/DEVELOPMENT.md
14
+
15
+ Documentation rules: the README stays short. A change to design, usage or behavior
16
+ updates the matching document in docs/ in the same change. DESIGN.md is kept current and
17
+ carries no project history.
18
+
19
+ Never run `qem start` (it costs real credit) unless the user asks for it.
qem-0.2.0/LICENSE ADDED
@@ -0,0 +1,12 @@
1
+ Copyright (C) 2026 by René de Hesselle
2
+
3
+ Permission to use, copy, modify, and/or distribute this software for any
4
+ purpose with or without fee is hereby granted.
5
+
6
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
7
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
8
+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
9
+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
10
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
11
+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
12
+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
@@ -0,0 +1,5 @@
1
+ Copyright (C) 2026 by René de Hesselle
2
+
3
+ Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted.
4
+
5
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
qem-0.2.0/PKG-INFO ADDED
@@ -0,0 +1,85 @@
1
+ Metadata-Version: 2.5
2
+ Name: qem
3
+ Version: 0.2.0
4
+ Summary: Python library and CLI for NoMac cloud Macs (nomac.app)
5
+ Author-email: René de Hesselle <dehesselle@web.de>
6
+ License-Expression: 0BSD
7
+ License-File: LICENSE
8
+ License-File: LICENSES/0BSD.txt
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Operating System :: MacOS
13
+ Classifier: Operating System :: Microsoft :: Windows
14
+ Classifier: Operating System :: POSIX :: Linux
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
20
+ Classifier: Topic :: Software Development :: Build Tools
21
+ Classifier: Topic :: Utilities
22
+ Requires-Python: >=3.12
23
+ Requires-Dist: httpx>=0.27
24
+ Requires-Dist: pathspec>=1.1.1
25
+ Requires-Dist: platformdirs>=4
26
+ Requires-Dist: pomona>=0.1
27
+ Requires-Dist: websockets>=17.1
28
+ Description-Content-Type: text/markdown
29
+
30
+ # quick ephemeral mac
31
+
32
+ `qem` is a Python library and command-line tool for
33
+ [NoMac](https://nomac.app) cloud Macs. It can start a Mac, run commands on it, open SSH
34
+ sessions and sync a project folder. It does this without Node or npm, and it runs natively
35
+ on Windows as well as on macOS and Linux.
36
+
37
+ It covers the session part of NoMac's official `nomac` CLI: login, start/stop, exec/jobs,
38
+ ssh, sync/pull, and the Mac's desktop in a window. Every command is a thin layer over
39
+ the library, so other Python programs can do the same things without the CLI.
40
+
41
+ ## Install and use
42
+
43
+ Requirements are Python 3.12+ and OpenSSH, on Windows x86_64, macOS on Apple silicon or
44
+ Linux x86_64 (the platforms that [pomona](https://pypi.org/project/pomona/), the desktop
45
+ viewer, has wheels for).
46
+
47
+ ```sh
48
+ uv tool install qem
49
+ qem login # approve the code in the browser
50
+ qem start --max-duration 30m --max-cost 1
51
+ qem sync .
52
+ qem exec -- xcodebuild -version
53
+ qem ssh
54
+ qem stop
55
+ ```
56
+
57
+ From Python, after `pip install qem`:
58
+
59
+ ```python
60
+ from qem import Nomac
61
+
62
+ with Nomac() as nm, nm.start(max_duration=1800, max_cost=1) as mac:
63
+ print(mac.exec(["sw_vers"]).stdout)
64
+ ```
65
+
66
+ A running Mac is billed per minute, so always pass `--max-cost` and `--max-duration`.
67
+
68
+ ## Documentation
69
+
70
+ - [docs/MANUAL.md](docs/MANUAL.md): every command and option, the library API, credentials
71
+ and local files.
72
+ - [docs/DESIGN.md](docs/DESIGN.md): architecture and core principles.
73
+ - [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md): setup, tests and the offline tunnel check.
74
+ - [docs/protocol.md](docs/protocol.md): the NoMac API and SSH tunnel as qem uses them,
75
+ where each fact comes from, and which parts are not yet verified against the live
76
+ service.
77
+
78
+ ## AI disclosure
79
+
80
+ Substantial parts of this code were generated with Opus 5.x. Generated code was
81
+ tested, but not every line was reviewed in detail.
82
+
83
+ ## License
84
+
85
+ [0BSD](LICENSE). qem is not affiliated with NoMac.
qem-0.2.0/README.md ADDED
@@ -0,0 +1,56 @@
1
+ # quick ephemeral mac
2
+
3
+ `qem` is a Python library and command-line tool for
4
+ [NoMac](https://nomac.app) cloud Macs. It can start a Mac, run commands on it, open SSH
5
+ sessions and sync a project folder. It does this without Node or npm, and it runs natively
6
+ on Windows as well as on macOS and Linux.
7
+
8
+ It covers the session part of NoMac's official `nomac` CLI: login, start/stop, exec/jobs,
9
+ ssh, sync/pull, and the Mac's desktop in a window. Every command is a thin layer over
10
+ the library, so other Python programs can do the same things without the CLI.
11
+
12
+ ## Install and use
13
+
14
+ Requirements are Python 3.12+ and OpenSSH, on Windows x86_64, macOS on Apple silicon or
15
+ Linux x86_64 (the platforms that [pomona](https://pypi.org/project/pomona/), the desktop
16
+ viewer, has wheels for).
17
+
18
+ ```sh
19
+ uv tool install qem
20
+ qem login # approve the code in the browser
21
+ qem start --max-duration 30m --max-cost 1
22
+ qem sync .
23
+ qem exec -- xcodebuild -version
24
+ qem ssh
25
+ qem stop
26
+ ```
27
+
28
+ From Python, after `pip install qem`:
29
+
30
+ ```python
31
+ from qem import Nomac
32
+
33
+ with Nomac() as nm, nm.start(max_duration=1800, max_cost=1) as mac:
34
+ print(mac.exec(["sw_vers"]).stdout)
35
+ ```
36
+
37
+ A running Mac is billed per minute, so always pass `--max-cost` and `--max-duration`.
38
+
39
+ ## Documentation
40
+
41
+ - [docs/MANUAL.md](docs/MANUAL.md): every command and option, the library API, credentials
42
+ and local files.
43
+ - [docs/DESIGN.md](docs/DESIGN.md): architecture and core principles.
44
+ - [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md): setup, tests and the offline tunnel check.
45
+ - [docs/protocol.md](docs/protocol.md): the NoMac API and SSH tunnel as qem uses them,
46
+ where each fact comes from, and which parts are not yet verified against the live
47
+ service.
48
+
49
+ ## AI disclosure
50
+
51
+ Substantial parts of this code were generated with Opus 5.x. Generated code was
52
+ tested, but not every line was reviewed in detail.
53
+
54
+ ## License
55
+
56
+ [0BSD](LICENSE). qem is not affiliated with NoMac.
qem-0.2.0/REUSE.toml ADDED
@@ -0,0 +1,12 @@
1
+ version = 1
2
+
3
+ # Files that can't carry an SPDX comment, or shouldn't: docs stay clean when
4
+ # rendered, uv.lock is regenerated.
5
+ [[annotations]]
6
+ path = [
7
+ "**.md",
8
+ "uv.lock",
9
+ ".python-version",
10
+ ]
11
+ SPDX-FileCopyrightText = "2026 René de Hesselle <dehesselle@web.de>"
12
+ SPDX-License-Identifier = "0BSD"
@@ -0,0 +1,158 @@
1
+ # Design
2
+
3
+ This document describes how qem is built and the rules the code keeps.
4
+
5
+ ## Architecture
6
+
7
+ qem has two layers.
8
+
9
+ - **Library** (`qem`, everything outside `qem.cli`): talks to the NoMac REST API,
10
+ builds ssh command lines, moves files over ssh, and runs the SSH tunnel.
11
+ - `transport.Transport` is the only place that does HTTP. It adds the Bearer key, the
12
+ `Idempotency-Key` header and the `wait` long-poll parameter, and turns error envelopes
13
+ into `errors.ApiError` subclasses.
14
+ - `nomac.Nomac` covers the account: `me`, `credits`, `sessions`, `start`, `session`.
15
+ - `mac_session.MacSession` is one Mac: lifecycle, jobs, `prepare_ssh`/`ssh`, `sync`/`pull`,
16
+ and `password`/`desktop_tunnel`.
17
+ - `models` holds typed, read-only views of responses. Each keeps the full response in
18
+ `raw`.
19
+ - `ssh`, `tunnel`, `upload` (sync), `transfer` (Mac paths, pull) and `vnc` (desktop) are
20
+ free functions that `MacSession` puts together.
21
+ - **CLI** (`qem.cli`): argparse in `cli/__init__.py`, handlers in `cli/commands.py`,
22
+ and the per-folder session store in `cli/project_store.py`.
23
+
24
+ SSH never reaches the Mac directly. ssh runs `python -m qem proxy <id>` as its
25
+ ProxyCommand, and that process carries the SSH bytes over a WebSocket to NoMac's gateway
26
+ (see [protocol.md](protocol.md)).
27
+
28
+ The desktop uses that ssh as well. An ssh port forward makes the Mac's Screen Sharing,
29
+ which listens only on the Mac's own 127.0.0.1:5900, reachable on this computer.
30
+ [pomona](https://pypi.org/project/pomona/), a dependency, signs in there with the
31
+ session's login from `/password` and shows the screen in a window. It speaks Apple's
32
+ Adaptive quality, which generic VNC viewers lack, so qem starts no external viewer.
33
+ pomona is imported only when the window opens, so the other commands do not load its
34
+ Rust extension. The nomac.app browser page is not used, because it needs the web login
35
+ rather than the API key.
36
+
37
+ Files move as tar streams over that ssh. Neither direction uses rsync.
38
+
39
+ - **`pull`** runs `tar -c` on the Mac and unpacks the stream with Python's `tarfile`.
40
+ - **`sync`** runs a small POSIX sh script on the Mac, over one ssh connection, using only
41
+ macOS's own tools. The exchange goes like this:
42
+ 1. The script lists the project folder with `find` and `stat`.
43
+ 2. qem compares the list with its own scan of the local folder.
44
+ 3. qem writes the paths to delete, then a tar stream of the new and changed files.
45
+ 4. The script reads the delete list with `read` and `dd bs=1`, which take no more than
46
+ they need, deletes, and hands the rest of stdin to `tar -x`.
47
+
48
+ Files are compared by type, size, whole-second modification time and permission bits.
49
+ `upload.ProjectFilter` holds the skip rules. It decides for local files and for paths
50
+ that exist only on the Mac, so `--delete` never removes what the rules leave out.
51
+ Secrets (`upload.SECRETS`) rank above `.gitignore`, so a project's own rules cannot
52
+ send them; only a `!` line in `.nomacignore` can. `.pem` and `.key` files count as
53
+ secrets only when their first 64 KiB hold a private key, so certificates and Keynote
54
+ documents still go. Names match in any case, as on Windows' and macOS's file systems.
55
+
56
+ qem does not use rsync because Windows has no rsync that just works:
57
+
58
+ - The native builds can't download from macOS's openrsync, which speaks only protocol 29.
59
+ - The MSYS2 and Cygwin builds rewrite paths when they start Windows OpenSSH.
60
+
61
+ [protocol.md](protocol.md) has the details. Without rsync there is one code path for
62
+ every platform, and it needs only ssh. What is lost is rsync's transfer of just the
63
+ changed parts of a file, which matters little for source code.
64
+
65
+ ## Core principles
66
+
67
+ 1. **The library never prints, prompts or calls `sys.exit`.** It returns values and
68
+ raises `NomacError` subclasses, because other programs embed it. Output, exit codes
69
+ and questions belong in `qem.cli`.
70
+ 2. **The library keeps no hidden per-project state.** Callers pass session ids, keys and
71
+ paths in. The map from project folder to session lives only in the CLI store, because
72
+ a program using the library decides on its own what a "current" Mac is. The only files
73
+ the library writes are SSH material in qem's folder (`config.home()`): generated
74
+ keys, pinned known_hosts, an empty ssh config, and the sockets ssh makes for shared
75
+ connections.
76
+ It also removes them again: `stop()` deletes the session's known_hosts and a key that
77
+ qem generated, and a start the API refuses deletes the key made for it. A key the
78
+ caller passed in is never deleted.
79
+ 3. **Commands are never run twice by accident.** `start` and `exec` always send an
80
+ `Idempotency-Key`. After a `NetworkError` the CLI prints the key to retry with, and the
81
+ library has no automatic retries. The API says to reuse a key and never retry with a
82
+ new one. A generated SSH key is named after the request key, so a retried start sends
83
+ the same body, and the key is kept after a `NetworkError`.
84
+ 4. **The tunnel's stdout carries SSH bytes and nothing else.** Every diagnostic goes to
85
+ stderr, because ssh reads stdout as the protocol stream.
86
+ 5. **Host keys are pinned, never trusted on first use.** The host key from the
87
+ `/access` call is written to a per-session known_hosts under the alias `qem-<id>`,
88
+ and ssh runs with `StrictHostKeyChecking=yes`. That alias is also the ssh hostname,
89
+ which is never resolved through DNS.
90
+ 6. **The access token and the Mac's password are never written to disk.** A parent that
91
+ already called `/access` passes the token to the ProxyCommand through
92
+ `QEM_TUNNEL_*` environment variables. On its own, `qem proxy` calls `/access` itself.
93
+ The password goes from memory to `pomona.connect`. `Login` keeps
94
+ it out of its repr. The token goes only to the gateway that `/access` named, and only
95
+ encrypted:
96
+ - `tunnel_url` accepts only a bare `https` origin, with no login, path or query.
97
+ - Plain `http` is accepted only for a loopback address, which the tests use.
98
+ - The session id is percent-encoded, so it cannot change the path.
99
+ - websockets drops the `Authorization` header when a redirect leads to another host.
100
+ 7. **Unknown response fields are tolerated.** The API has no published schema
101
+ ([protocol.md](protocol.md)). Models read fields defensively, unwrap `{"session": …}`
102
+ and `{"job": …}` when present, and keep `raw` so callers are not blocked by a missing
103
+ field.
104
+ 8. **Separate from the npm CLI.** Everything lives in qem's own folder, not `~/.nomac`,
105
+ and the command is `qem`. The npm CLI's file formats are undocumented, and both
106
+ tools may be installed side by side. The npm CLI is UNLICENSED: qem takes only
107
+ protocol facts from it (listed in [protocol.md](protocol.md)), never its code,
108
+ structure or wording. Names, defaults and behaviour come from NoMac's public docs, the
109
+ API or qem's own design. Timeouts rest on measured timings. qem does not imitate the
110
+ npm CLI where the service does not require it.
111
+
112
+ ## Platform choices
113
+
114
+ - qem's folder is the user config folder from platformdirs, not a dot-folder in the
115
+ home folder. Everything goes in that one folder, keys and sockets included, so
116
+ `QEM_HOME` moves all of it. On Windows it is the local, non-roaming AppData, so
117
+ private keys are not copied between machines. The macOS folder contains a space
118
+ (`Application Support`), so every path qem writes into an ssh option or config,
119
+ including the `Include` line of `ssh-config`, is quoted when it has one.
120
+ - Paths inside ssh options use forward slashes on Windows. Windows OpenSSH accepts them,
121
+ and MSYS ssh would otherwise lose the backslashes when it runs the ProxyCommand through
122
+ `/bin/sh`.
123
+ - An empty file in qem's folder stands in for `/dev/null` (`-F`,
124
+ `GlobalKnownHostsFile`), because Windows OpenSSH does not understand `/dev/null`.
125
+ - Windows OpenSSH stops writing its stdout in session 0 (ssh logins, services), so
126
+ there qem uses Git's ssh instead, found only next to the `git` on `PATH`. The session
127
+ comes from `ProcessIdToSessionId` through ctypes. qem replaces only Windows' own
128
+ OpenSSH and never searches the disk; `QEM_SSH` overrides the choice.
129
+ - MSYS ssh runs the ProxyCommand through `$SHELL`, which in an ssh login to Windows is
130
+ that server's shell, often PowerShell. For an ssh with `sh.exe` beside it,
131
+ `prepare_ssh()` therefore sets `SHELL` to that `sh.exe` in ssh's environment.
132
+ - ControlMaster multiplexing is used only off Windows, where OpenSSH supports it.
133
+ - Each Mac gets one socket in `mux/` in qem's folder, named by a 16-digit hash of the
134
+ session id.
135
+ - When that path is too long for a Unix socket (macOS allows 104 bytes, and ssh adds
136
+ 17 for a temporary name), ssh runs without sharing.
137
+ - The shared connection closes 60 s after its last use. It is kept short because an
138
+ open connection may keep the Mac from counting as idle.
139
+ - A port forward always gets a connection of its own. Then the port closes when that
140
+ ssh ends, and a taken port fails it at once (`ExitOnForwardFailure`).
141
+ - ssh sends keepalives every 15 s and gives up after 8 missed answers. The provider's
142
+ network can stall the tunnel for a while, so ssh should not give up sooner.
143
+ `ConnectTimeout=30` limits the wait for the Mac's SSH banner. The tunnel itself
144
+ gives up on the WebSocket handshake after 15 s.
145
+ - On Windows, a generated private key gets an ACL that lists only the current user
146
+ (`icacls`). Windows OpenSSH ignores keys that anyone else may read, and MSYS
147
+ `ssh-keygen` adds an OWNER RIGHTS entry that counts as that.
148
+ - `pull` unpacks with `tarfile`'s `data` filter, so an archive from the Mac cannot write
149
+ outside the target folder.
150
+ - Windows has no executable bit, so there `sync` takes it from Git's index. Untracked
151
+ files count as executable when they start with `#!`. Other platforms use the file's
152
+ own permission bits.
153
+ - The project folder on the Mac has the local folder's name, unchanged apart from NFC.
154
+ Every shell command quotes it, so no character needs replacing. Only an empty name (a
155
+ drive root) and one over macOS's 255 bytes are refused.
156
+ - `sync` compares file names in Unicode NFC form. After `tar -x`, macOS listed a name
157
+ decomposed (NFD), while Windows keeps it composed, so the two spellings would never
158
+ match. Deletions use the name exactly as the Mac stores it.
@@ -0,0 +1,110 @@
1
+ # Development
2
+
3
+ This document covers setting up, testing, versioning and releasing qem.
4
+
5
+ ## Setup
6
+
7
+ ```sh
8
+ uv sync # creates .venv, installs qem editable plus pytest
9
+ uv run qem --help
10
+ ```
11
+
12
+ The version comes from git tags through hatch-vcs and is written to the generated
13
+ `src/qem/version.py`. Tag releases as `vX.Y.Z`. After a new commit or tag, run
14
+ `uv sync --reinstall-package qem` to refresh that file.
15
+
16
+ The project is REUSE compliant. A new file starts with the SPDX header that
17
+ `reuse annotate --copyright "René de Hesselle <dehesselle@web.de>" --license 0BSD`
18
+ writes. A file that can't carry a comment goes in `REUSE.toml` instead.
19
+
20
+ ## Tests
21
+
22
+ ```sh
23
+ uv run pytest
24
+ uvx --from 'ruff~=0.16.0' ruff format --check .
25
+ uvx --from 'ruff~=0.16.0' ruff check . # default rules; the generated version.py is excluded
26
+ uvx reuse lint # every file has copyright and license information
27
+ ```
28
+
29
+ - None of the tests touch the real NoMac service. `QEM_HOME` points at a temporary
30
+ folder and `NOMAC_API_KEY` is a dummy.
31
+ - `tests/conftest.py` has `FakeApi`, a route table for `httpx.MockTransport`. Library tests
32
+ build `Nomac(http_client=…, sleep=…)` with it.
33
+ - `test_library.py` runs the library on its own and fails if anything prints or calls
34
+ `sys.exit` (core principle 1 in [DESIGN.md](DESIGN.md)).
35
+ - `test_cli.py` calls `qem.cli.main()` with `Nomac` patched to use the fake API, and
36
+ checks exit codes (including 75) and output.
37
+ - `test_upload.py` covers `sync` without a Mac: the skip rules, the local scan, the
38
+ parsing of the Mac's file list, what gets sent and removed, and the tar stream. The
39
+ Mac-side shell script is checked only against the live service.
40
+ - `test_vnc.py` covers the desktop without a Mac. A small Python process that opens the
41
+ port stands in for ssh, and the test checks that the tunnel waits for the port, reports
42
+ ssh's errors, and is closed afterwards. A stand-in `pomona` module checks the sign-in
43
+ and that a reconnect reopens a tunnel whose ssh ended. No test opens a window.
44
+ - `test_tunnel.py` starts a local WebSocket server and runs `python -m qem proxy`
45
+ as a real subprocess. It checks that every byte value passes through unchanged, that
46
+ the Bearer header reaches the server, and that a refused handshake ends with exit code
47
+ 255 and nothing on stdout. It also pins which gateway URLs `tunnel_url` refuses.
48
+ - `test_ssh_transfer.py` checks the ssh command lines: host-key pinning, batch mode,
49
+ multiplexing and forwards. It also runs the real `ssh-keygen` when there is one.
50
+ - `test_ssh_choice.py` covers which ssh qem runs (`QEM_SSH`, Git's ssh without a desktop
51
+ on Windows), on a fake Windows layout.
52
+ - `test_device_login.py` covers the device login, including RFC 8628's `slow_down` and the
53
+ login's expiry.
54
+
55
+ ## CI and releases
56
+
57
+ `.github/workflows/build.yml` runs on every push and pull request. It runs the format,
58
+ lint and REUSE checks, the tests on Linux, Windows and macOS, and builds the sdist and
59
+ wheel. It also installs the wheel and checks that `qem --version` runs.
60
+
61
+ To release, push a `vX.Y.Z` tag. CI checks that the built version matches the tag,
62
+ creates a draft GitHub release with the files from `dist/`, and publishes them to
63
+ [PyPI](https://pypi.org/p/qem) through trusted publishing (the `pypi` environment). No
64
+ token is stored. Publishing the draft release on GitHub is a manual step.
65
+
66
+ ## Checking ssh without a NoMac account
67
+
68
+ The unit tests do not cover a real ssh client running the ProxyCommand. To check that
69
+ part, run a local WebSocket server that bridges to any public SSH server (for example
70
+ `github.com:22`). Then set `QEM_TUNNEL_SESSION`, `QEM_TUNNEL_GATEWAY`
71
+ (`http://127.0.0.1:<port>`) and `QEM_TUNNEL_TOKEN`, write that server's host key with
72
+ `qem.ssh.write_known_hosts`, and run `qem.ssh.ssh_argv(...)`.
73
+
74
+ ssh should get as far as `Permission denied (publickey)`. That means the ProxyCommand,
75
+ the tunnel and host-key pinning work. Try it with Windows OpenSSH (from PowerShell) and
76
+ with Git's MSYS ssh (from Git Bash).
77
+
78
+ On a Linux devbox, bridge to a throwaway `sshd -D -f <config>` on another port, with its
79
+ own host key and an `AuthorizedKeysFile` holding a key from `generate_identity`. Set
80
+ `ssh.USERNAME` to a user that exists there. Root needs `UsePAM yes` if its password is
81
+ locked. With a key that is accepted, this also checks commands, binary stdin,
82
+ connection sharing and `--forward`.
83
+
84
+ On a Windows devbox, bridge to a Unix sshd (a Linux devbox's port 22 works), not to the
85
+ box's own OpenSSH Server: its PowerShell login shell mangles binary stdin. Inside an ssh
86
+ session (session 0), Windows OpenSSH 9.5 stops writing its stdout, even without qem, so
87
+ qem picks Git's ssh there. To check Windows OpenSSH itself, run the check in a signed-in
88
+ desktop session, for example as a scheduled task with `schtasks /IT`, or set `QEM_SSH`
89
+ to it and expect the hang.
90
+
91
+ ## Against the live service
92
+
93
+ The end-to-end check uses real credit. Cap it:
94
+
95
+ ```sh
96
+ qem start --max-duration 30m --max-cost 1
97
+ qem exec -- sw_vers
98
+ qem exec --wait 5 -- sleep 20; qem job <JOB_ID> --wait 60
99
+ qem ssh -- uname -a
100
+ qem sync . && qem sync . && qem exec -- ls # the second sync sends 0 files
101
+ qem pull ./tmp-pull --from README.md
102
+ qem password --copy
103
+ qem desktop # pomona's window opens signed in; closing it ends the command
104
+ qem stop
105
+ ```
106
+
107
+ Record what the service actually returned in [protocol.md](protocol.md).
108
+
109
+ On Windows, run this in PowerShell (Windows OpenSSH) and in Git Bash (MSYS ssh). In Git
110
+ Bash, set `MSYS_NO_PATHCONV=1` for absolute Mac paths.