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.
- qem-0.2.0/.editorconfig +14 -0
- qem-0.2.0/.gitattributes +5 -0
- qem-0.2.0/.github/workflows/build.yml +152 -0
- qem-0.2.0/.gitignore +20 -0
- qem-0.2.0/.python-version +1 -0
- qem-0.2.0/CLAUDE.md +19 -0
- qem-0.2.0/LICENSE +12 -0
- qem-0.2.0/LICENSES/0BSD.txt +5 -0
- qem-0.2.0/PKG-INFO +85 -0
- qem-0.2.0/README.md +56 -0
- qem-0.2.0/REUSE.toml +12 -0
- qem-0.2.0/docs/DESIGN.md +158 -0
- qem-0.2.0/docs/DEVELOPMENT.md +110 -0
- qem-0.2.0/docs/MANUAL.md +267 -0
- qem-0.2.0/docs/protocol.md +188 -0
- qem-0.2.0/pyproject.toml +66 -0
- qem-0.2.0/src/qem/__init__.py +63 -0
- qem-0.2.0/src/qem/__main__.py +7 -0
- qem-0.2.0/src/qem/cli/__init__.py +202 -0
- qem-0.2.0/src/qem/cli/commands.py +629 -0
- qem-0.2.0/src/qem/cli/project_store.py +99 -0
- qem-0.2.0/src/qem/config.py +75 -0
- qem-0.2.0/src/qem/device_login.py +143 -0
- qem-0.2.0/src/qem/errors.py +95 -0
- qem-0.2.0/src/qem/mac_session.py +316 -0
- qem-0.2.0/src/qem/models.py +197 -0
- qem-0.2.0/src/qem/nomac.py +145 -0
- qem-0.2.0/src/qem/ssh.py +430 -0
- qem-0.2.0/src/qem/transfer.py +97 -0
- qem-0.2.0/src/qem/transport.py +79 -0
- qem-0.2.0/src/qem/tunnel.py +189 -0
- qem-0.2.0/src/qem/upload.py +579 -0
- qem-0.2.0/src/qem/version.py +24 -0
- qem-0.2.0/src/qem/version_info.py +11 -0
- qem-0.2.0/src/qem/vnc.py +207 -0
- qem-0.2.0/tests/conftest.py +74 -0
- qem-0.2.0/tests/test_cli.py +504 -0
- qem-0.2.0/tests/test_device_login.py +235 -0
- qem-0.2.0/tests/test_library.py +289 -0
- qem-0.2.0/tests/test_ssh_choice.py +133 -0
- qem-0.2.0/tests/test_ssh_transfer.py +272 -0
- qem-0.2.0/tests/test_tunnel.py +172 -0
- qem-0.2.0/tests/test_upload.py +304 -0
- qem-0.2.0/tests/test_vnc.py +205 -0
- qem-0.2.0/uv.lock +492 -0
qem-0.2.0/.editorconfig
ADDED
|
@@ -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
|
qem-0.2.0/.gitattributes
ADDED
|
@@ -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"
|
qem-0.2.0/docs/DESIGN.md
ADDED
|
@@ -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.
|