dark-chat 2.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,4 @@
1
+ include README.md
2
+ recursive-include docs *.md
3
+ recursive-include scripts *.py
4
+ exclude docs/node-version.md
@@ -0,0 +1,150 @@
1
+ Metadata-Version: 2.4
2
+ Name: dark-chat
3
+ Version: 2.1.0
4
+ Summary: Minimal two-person encrypted terminal chat and WebSocket relay
5
+ Project-URL: Repository, https://github.com/iZubayr/dark-terminal-chat
6
+ Project-URL: Issues, https://github.com/iZubayr/dark-terminal-chat/issues
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Environment :: Console
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Topic :: Communications :: Chat
11
+ Requires-Python: >=3.10
12
+ Description-Content-Type: text/markdown
13
+ Requires-Dist: cryptography<50,>=44
14
+ Requires-Dist: websockets<18,>=15
15
+ Requires-Dist: prompt-toolkit<4,>=3.0.48
16
+
17
+ # Dark Terminal Chat
18
+
19
+ A plain, English terminal chat for two people. No banner, logo, animation, or forced color. Python 3.10 or newer is required. The client and WebSocket server install from the same Python package.
20
+
21
+ Version 2 uses a new session protocol. Use version 2 clients and servers together. Source: [GitHub](https://github.com/iZubayr/dark-terminal-chat).
22
+
23
+ ## Install
24
+
25
+ Install from [PyPI](https://pypi.org/project/dark-chat/):
26
+
27
+ ~~~sh
28
+ pip install dark-chat
29
+ ~~~
30
+
31
+ Create a chat:
32
+
33
+ ~~~sh
34
+ dark-chat --new
35
+ ~~~
36
+
37
+ Join a chat:
38
+
39
+ ~~~sh
40
+ dark-chat --chat
41
+ ~~~
42
+
43
+ Enter your name when asked. The creator receives a code and shares it with the peer. The peer enters that code when asked. Both commands connect to the hosted server automatically.
44
+
45
+ For development, install from this folder on Windows:
46
+
47
+ ~~~powershell
48
+ python -m venv .venv
49
+ .\.venv\Scripts\Activate.ps1
50
+ python -m pip install .
51
+ ~~~
52
+
53
+ On Linux or macOS:
54
+
55
+ ~~~sh
56
+ python3 -m venv .venv
57
+ . .venv/bin/activate
58
+ python -m pip install .
59
+ ~~~
60
+
61
+ The package provides two commands: dark-chat and dark-chat-server. You can also use python -m dark_terminal_chat and python -m dark_terminal_chat.server.
62
+
63
+ ## Local use
64
+
65
+ Open three terminals with the Python environment activated.
66
+
67
+ ~~~sh
68
+ # Server
69
+ dark-chat-server
70
+
71
+ # Creator
72
+ dark-chat --server ws://127.0.0.1:8080/ws --new --name elliot
73
+
74
+ # Peer, in another terminal
75
+ dark-chat --server ws://127.0.0.1:8080/ws --chat --name whiterose
76
+ ~~~
77
+
78
+ The creator receives a line starting with "Code:". The peer pastes that code at "Code:". The code is hidden while entering it. If no name is supplied, the program asks "Name:". Running dark-chat without either mode asks "Create or join? [c/j]:".
79
+
80
+ The interface contains only prompts, messages, and relevant connection/error notices. Example:
81
+
82
+ ~~~text
83
+ Code: <invite code>
84
+ Waiting for peer.
85
+ whiterose joined.
86
+ whiterose> Hello.
87
+ elliot>
88
+ ~~~
89
+
90
+ On Windows, chat.cmd and server.cmd use this folder's .venv when available.
91
+
92
+ ## Session rules
93
+
94
+ - Each chat has two participant slots. A third participant is refused.
95
+ - Leaving with /exit or Ctrl+C releases the participant's slot immediately while connected.
96
+ - When both participants leave, the room is removed. Joining with its old code fails; create a new chat for a new code.
97
+ - A dropped connection reserves the same participant's slot for 60 seconds. Only that participant's in-memory resume token can reclaim it.
98
+ - The client tries to reconnect for up to 60 seconds. When a server restarts, room state is lost and a new chat is needed.
99
+ - Sending is paused while the peer is offline. The draft remains editable.
100
+ - Messages are not queued or automatically resent. If receipt isn't confirmed, the draft is kept and a short notice is shown. A lost receipt can mean the peer already saw the message; check before manually resending.
101
+ - Messages and drafts are not written to files. Old messages are not replayed after reconnecting.
102
+
103
+ ## Internet use
104
+
105
+ The deployed server is available at wss://zubayr.alwaysdata.net/dark-chat/ws:
106
+
107
+ ~~~sh
108
+ dark-chat --new
109
+ dark-chat --chat
110
+ ~~~
111
+
112
+ Share the code with the peer. To use your own relay, pass --server wss://YOUR_SERVER/ws. Use wss:// for internet connections. TLS certificates are checked; --ca ca.pem supports a private certificate authority.
113
+
114
+ DARK_CHAT_SERVER can override the default relay. --server overrides that environment variable. DARK_CHAT_CODE is supported for non-interactive clients; interactive clients ask for the code instead.
115
+
116
+ For local Wi-Fi, use --host 0.0.0.0 on the server and --server ws://LAN_IP:8080/ws --allow-insecure on clients.
117
+
118
+ [AlwaysData setup beside MediaHub](docs/alwaysdata.md).
119
+
120
+ ## Commands
121
+
122
+ | Command | Action |
123
+ | --- | --- |
124
+ | /help | List commands |
125
+ | /who | Show the connected participants whose names are known |
126
+ | /clear | Clear the screen |
127
+ | /exit or Ctrl+C | Leave |
128
+
129
+ ## Privacy
130
+
131
+ Messages and names are encrypted on the client with AES-256-GCM. Keys are derived from a random 32-byte invite code with HKDF-SHA256. The invite code is never sent to the relay. The relay sees opaque room/session identifiers, IP addresses, message sizes, timing, and delivery-control metadata.
132
+
133
+ An invite code is shared access, and a name is not verified identity. Keep the code private. The terminal scrollback may retain displayed messages and the creator's code; /clear is not secure erasure. There is no forward secrecy, anonymity guarantee, or independent security audit.
134
+
135
+ The relay holds only session metadata and temporary network buffers. There is no message history or application-level message queue. The default server limit is 128 connections/rooms, with two participant slots per room and a 40-packet limit per connection per 10 seconds. Run one relay process; separate instances do not share rooms.
136
+
137
+ ## Build and check
138
+
139
+ ~~~sh
140
+ python -m pip install -e . build twine
141
+ python -m unittest discover -s tests -v
142
+ python -m build
143
+ python -m twine check dist/*
144
+ ~~~
145
+
146
+ [Package distribution](docs/distribution.md).
147
+
148
+ GitHub Actions tests the installed wheel on Linux with Python 3.10/3.12 and on Windows with Python 3.12. After all jobs pass, it advances the deploy branch. AlwaysData checks that branch every five minutes and tests the candidate again before changing the running version. See [automatic updates](docs/alwaysdata.md#automatic-updates).
149
+
150
+ The earlier Node.js TCP experiment remains as reference source only. It does not support the current session protocol. The Python client/server is the application.
@@ -0,0 +1,134 @@
1
+ # Dark Terminal Chat
2
+
3
+ A plain, English terminal chat for two people. No banner, logo, animation, or forced color. Python 3.10 or newer is required. The client and WebSocket server install from the same Python package.
4
+
5
+ Version 2 uses a new session protocol. Use version 2 clients and servers together. Source: [GitHub](https://github.com/iZubayr/dark-terminal-chat).
6
+
7
+ ## Install
8
+
9
+ Install from [PyPI](https://pypi.org/project/dark-chat/):
10
+
11
+ ~~~sh
12
+ pip install dark-chat
13
+ ~~~
14
+
15
+ Create a chat:
16
+
17
+ ~~~sh
18
+ dark-chat --new
19
+ ~~~
20
+
21
+ Join a chat:
22
+
23
+ ~~~sh
24
+ dark-chat --chat
25
+ ~~~
26
+
27
+ Enter your name when asked. The creator receives a code and shares it with the peer. The peer enters that code when asked. Both commands connect to the hosted server automatically.
28
+
29
+ For development, install from this folder on Windows:
30
+
31
+ ~~~powershell
32
+ python -m venv .venv
33
+ .\.venv\Scripts\Activate.ps1
34
+ python -m pip install .
35
+ ~~~
36
+
37
+ On Linux or macOS:
38
+
39
+ ~~~sh
40
+ python3 -m venv .venv
41
+ . .venv/bin/activate
42
+ python -m pip install .
43
+ ~~~
44
+
45
+ The package provides two commands: dark-chat and dark-chat-server. You can also use python -m dark_terminal_chat and python -m dark_terminal_chat.server.
46
+
47
+ ## Local use
48
+
49
+ Open three terminals with the Python environment activated.
50
+
51
+ ~~~sh
52
+ # Server
53
+ dark-chat-server
54
+
55
+ # Creator
56
+ dark-chat --server ws://127.0.0.1:8080/ws --new --name elliot
57
+
58
+ # Peer, in another terminal
59
+ dark-chat --server ws://127.0.0.1:8080/ws --chat --name whiterose
60
+ ~~~
61
+
62
+ The creator receives a line starting with "Code:". The peer pastes that code at "Code:". The code is hidden while entering it. If no name is supplied, the program asks "Name:". Running dark-chat without either mode asks "Create or join? [c/j]:".
63
+
64
+ The interface contains only prompts, messages, and relevant connection/error notices. Example:
65
+
66
+ ~~~text
67
+ Code: <invite code>
68
+ Waiting for peer.
69
+ whiterose joined.
70
+ whiterose> Hello.
71
+ elliot>
72
+ ~~~
73
+
74
+ On Windows, chat.cmd and server.cmd use this folder's .venv when available.
75
+
76
+ ## Session rules
77
+
78
+ - Each chat has two participant slots. A third participant is refused.
79
+ - Leaving with /exit or Ctrl+C releases the participant's slot immediately while connected.
80
+ - When both participants leave, the room is removed. Joining with its old code fails; create a new chat for a new code.
81
+ - A dropped connection reserves the same participant's slot for 60 seconds. Only that participant's in-memory resume token can reclaim it.
82
+ - The client tries to reconnect for up to 60 seconds. When a server restarts, room state is lost and a new chat is needed.
83
+ - Sending is paused while the peer is offline. The draft remains editable.
84
+ - Messages are not queued or automatically resent. If receipt isn't confirmed, the draft is kept and a short notice is shown. A lost receipt can mean the peer already saw the message; check before manually resending.
85
+ - Messages and drafts are not written to files. Old messages are not replayed after reconnecting.
86
+
87
+ ## Internet use
88
+
89
+ The deployed server is available at wss://zubayr.alwaysdata.net/dark-chat/ws:
90
+
91
+ ~~~sh
92
+ dark-chat --new
93
+ dark-chat --chat
94
+ ~~~
95
+
96
+ Share the code with the peer. To use your own relay, pass --server wss://YOUR_SERVER/ws. Use wss:// for internet connections. TLS certificates are checked; --ca ca.pem supports a private certificate authority.
97
+
98
+ DARK_CHAT_SERVER can override the default relay. --server overrides that environment variable. DARK_CHAT_CODE is supported for non-interactive clients; interactive clients ask for the code instead.
99
+
100
+ For local Wi-Fi, use --host 0.0.0.0 on the server and --server ws://LAN_IP:8080/ws --allow-insecure on clients.
101
+
102
+ [AlwaysData setup beside MediaHub](docs/alwaysdata.md).
103
+
104
+ ## Commands
105
+
106
+ | Command | Action |
107
+ | --- | --- |
108
+ | /help | List commands |
109
+ | /who | Show the connected participants whose names are known |
110
+ | /clear | Clear the screen |
111
+ | /exit or Ctrl+C | Leave |
112
+
113
+ ## Privacy
114
+
115
+ Messages and names are encrypted on the client with AES-256-GCM. Keys are derived from a random 32-byte invite code with HKDF-SHA256. The invite code is never sent to the relay. The relay sees opaque room/session identifiers, IP addresses, message sizes, timing, and delivery-control metadata.
116
+
117
+ An invite code is shared access, and a name is not verified identity. Keep the code private. The terminal scrollback may retain displayed messages and the creator's code; /clear is not secure erasure. There is no forward secrecy, anonymity guarantee, or independent security audit.
118
+
119
+ The relay holds only session metadata and temporary network buffers. There is no message history or application-level message queue. The default server limit is 128 connections/rooms, with two participant slots per room and a 40-packet limit per connection per 10 seconds. Run one relay process; separate instances do not share rooms.
120
+
121
+ ## Build and check
122
+
123
+ ~~~sh
124
+ python -m pip install -e . build twine
125
+ python -m unittest discover -s tests -v
126
+ python -m build
127
+ python -m twine check dist/*
128
+ ~~~
129
+
130
+ [Package distribution](docs/distribution.md).
131
+
132
+ GitHub Actions tests the installed wheel on Linux with Python 3.10/3.12 and on Windows with Python 3.12. After all jobs pass, it advances the deploy branch. AlwaysData checks that branch every five minutes and tests the candidate again before changing the running version. See [automatic updates](docs/alwaysdata.md#automatic-updates).
133
+
134
+ The earlier Node.js TCP experiment remains as reference source only. It does not support the current session protocol. The Python client/server is the application.
@@ -0,0 +1,97 @@
1
+ # AlwaysData deployment
2
+
3
+ Deploy this chat as a separate site, directory, and virtual environment beside MediaHub. The applications share the account's resource allowance. The hosting supervisor limits the relay to 32 connections.
4
+
5
+ AlwaysData supports [WebSocket sites](https://help.alwaysdata.com/en/blog/2023-03-14-hold-on-to-your-socks-high-speed-data-stream-hosting-with-websockets/). External clients use HTTPS/WSS, while the relay listens on the site's internal IP/port.
6
+
7
+ ## Deployed instance
8
+
9
+ The zubayr account serves the chat at wss://zubayr.alwaysdata.net/dark-chat/ws. Health: https://zubayr.alwaysdata.net/dark-chat/health. Its clone lives at /home/zubayr/dark-chat. The separate Dark Terminal site is 1084884; scheduled task 34302 checks for updates every five minutes. MediaHub remains the root site.
10
+
11
+ On 2026-10-08, the hosting environment passed all 22 tests. Two clients installed from the GitHub release exchanged messages through the public WSS endpoint. The same check verified encrypted Unicode messages, refusal of a third participant, session resumption, and rejection of an old invite after both participants left. MediaHub's /health returned HTTP 200 before and after the check.
12
+
13
+ ## Install
14
+
15
+ In the AlwaysData SSH terminal (replace ACCOUNT with the account name):
16
+
17
+ ~~~sh
18
+ cd /home/ACCOUNT
19
+ git clone --branch deploy https://github.com/iZubayr/dark-terminal-chat.git dark-chat
20
+ cd dark-chat
21
+ python3 scripts/update.py
22
+ .deploy/current/venv/bin/dark-chat-server --version
23
+ ~~~
24
+
25
+ Python 3.10+ is required. GitHub Actions advances the deploy branch only after every test job passes. The updater installs that approved commit and tests it again in its own environment. The deploy branch becomes available after the first successful Tests workflow.
26
+
27
+ ## Add a site
28
+
29
+ In the AlwaysData panel, choose Web → Sites → Add a site. See [User program configuration](https://help.alwaysdata.com/en/docs/web-hosting/sites/http-servers/user-program/).
30
+
31
+ | Field | Value |
32
+ | --- | --- |
33
+ | Name | Dark Terminal |
34
+ | Addresses | ACCOUNT.alwaysdata.net/dark-chat |
35
+ | Type | User program |
36
+ | Command | python3 /home/ACCOUNT/dark-chat/scripts/run_server.py |
37
+ | Working directory | /home/ACCOUNT/dark-chat |
38
+ | Environment | IP and PORT: the values assigned to this new site in the panel |
39
+ | Trim path | Enabled |
40
+ | Idle time | 0 |
41
+
42
+ Replace ACCOUNT with your actual account name. If IP/PORT are not supplied automatically, set them in Environment or pass --host PANEL_IP --port PANEL_PORT in Command. Use the new site's assigned port.
43
+
44
+ Trim path maps external /dark-chat/ws to internal /ws, and /dark-chat/health to /health. AlwaysData handles external TLS. Start/restart the new chat site.
45
+
46
+ ## Verify
47
+
48
+ ~~~sh
49
+ curl --fail https://ACCOUNT.alwaysdata.net/dark-chat/health
50
+ ~~~
51
+
52
+ Expected response:
53
+
54
+ ~~~json
55
+ {"status":"ok"}
56
+ ~~~
57
+
58
+ Then use two computers:
59
+
60
+ ~~~sh
61
+ dark-chat --server wss://ACCOUNT.alwaysdata.net/dark-chat/ws --new --name elliot
62
+ dark-chat --server wss://ACCOUNT.alwaysdata.net/dark-chat/ws --name whiterose
63
+ ~~~
64
+
65
+ The creator shares the invite code with the peer. Confirm messages arrive in both directions, a third client is refused, and MediaHub still responds normally. Local tests do not prove production behavior.
66
+
67
+ ## Restarts and upgrades
68
+
69
+ Use version 2 clients with a version 2 server. The session protocol is incompatible with version 1.
70
+
71
+ Run one relay process. Rooms are in memory, so a server restart ends existing sessions and invalidates join codes. Clients handle ordinary connection drops for 60 seconds; they cannot reconstruct rooms after a server restart.
72
+
73
+ For a 503 response, check this site's startup log, command, environment, and IP/PORT. If health works but chat doesn't, check the public WSS URL, Trim path, TLS, and site settings affecting WebSocket connections.
74
+
75
+ ## Automatic updates
76
+
77
+ Add a separate AlwaysData scheduled task:
78
+
79
+ | Field | Value |
80
+ | --- | --- |
81
+ | Type | Command |
82
+ | Frequency | Every 5 minutes |
83
+ | Command | See the bootstrap command below |
84
+ | Working directory | /home/ACCOUNT/dark-chat |
85
+ | Annotation | Dark Terminal updates |
86
+
87
+ Scheduled task command (replace ACCOUNT):
88
+
89
+ ~~~sh
90
+ /bin/bash -c 'set -eu; cd /home/ACCOUNT/dark-chat; git fetch origin main deploy; mkdir -p .deploy; git show origin/deploy:scripts/update.py > .deploy/update-current.py; exec python3 .deploy/update-current.py'
91
+ ~~~
92
+
93
+ The bootstrap uses the approved upstream updater, so fixes to deployment itself can take effect. The updater fetches the deploy branch published by GitHub Tests, verifies that its commit belongs to main, and builds a separate release environment. Before building, it removes inactive generated release environments inside this clone to fit the hosting disk quota; the current release is preserved. It runs the tests there before fast-forwarding the clone and atomically changing .deploy/current. Pip runs in isolated mode with --no-user and --no-cache-dir. The supervisor then restarts only the chat child process. No GitHub SSH private key, API token, or unauthenticated GitHub API call is needed on the server.
94
+
95
+ Failed tests, failed installs, local edits, or diverged commits leave the active release unchanged. A lock prevents overlapping updates. The current release and one candidate are retained; older environments can be rebuilt from Git if needed. A successful upgrade ends any current chats because rooms exist only in memory.
96
+
97
+ For a manual update, run python3 scripts/update.py from the clone. The scheduled task uses the same path. To roll back, point .deploy/current at a known previous release; the supervisor notices the change. Disable the scheduled task while investigating so it does not immediately reapply main.
@@ -0,0 +1,44 @@
1
+ # Distributing dark-chat
2
+
3
+ The package name and client command are both dark-chat. Python 3.10+ is required. Version 2.1.0 connects to wss://zubayr.alwaysdata.net/dark-chat/ws by default.
4
+
5
+ The public client commands are:
6
+
7
+ ~~~sh
8
+ pip install dark-chat
9
+ dark-chat --new
10
+ dark-chat --chat
11
+ ~~~
12
+
13
+ The creator shares the code with the peer. Names and codes are entered at the terminal prompts. The server address is optional; --server and DARK_CHAT_SERVER support other relays.
14
+
15
+ ## Build and verify
16
+
17
+ ~~~sh
18
+ python -m build --outdir dist/2.1.0
19
+ python -m twine check dist/2.1.0/*
20
+ python -m pip install dist/2.1.0/dark_chat-2.1.0-py3-none-any.whl
21
+ python -m pip check
22
+ python -m unittest discover -s tests -v
23
+ dark-chat --version
24
+ ~~~
25
+
26
+ Keep earlier dark-terminal-chat distribution files separate. Publish only the new dark-chat distribution. The import module remains dark_terminal_chat, so a client environment should contain one of these distributions at a time.
27
+
28
+ ## PyPI publishing
29
+
30
+ Configure a pending GitHub Trusted Publisher under [PyPI account publishing](https://pypi.org/manage/account/publishing/):
31
+
32
+ | Field | Value |
33
+ | --- | --- |
34
+ | PyPI project name | dark-chat |
35
+ | GitHub owner | iZubayr |
36
+ | Repository | dark-terminal-chat |
37
+ | Workflow filename | ci.yml |
38
+ | Environment | pypi |
39
+
40
+ Then run the Tests workflow on main with the publish input enabled. It builds and installs the package, runs tests on Linux with Python 3.10/3.12 and Windows with Python 3.12, and checks the installed dark-chat command. Only after all jobs pass does the publish job upload the tested Linux 3.12 artifact to PyPI using short-lived OIDC credentials. No permanent PyPI API token is stored in the repository or on AlwaysData.
41
+
42
+ After publishing, install dark-chat from the normal PyPI index in a fresh environment. Verify --new and --chat exchange messages through the default public relay. A built wheel or successful GitHub workflow without a successful PyPI publish does not establish that pip install dark-chat works.
43
+
44
+ [PyPI Trusted Publishing documentation](https://docs.pypi.org/trusted-publishers/creating-a-project-through-oidc/).
@@ -0,0 +1,32 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "dark-chat"
7
+ version = "2.1.0"
8
+ description = "Minimal two-person encrypted terminal chat and WebSocket relay"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ dependencies = [
12
+ "cryptography>=44,<50",
13
+ "websockets>=15,<18",
14
+ "prompt-toolkit>=3.0.48,<4",
15
+ ]
16
+ classifiers = [
17
+ "Programming Language :: Python :: 3",
18
+ "Environment :: Console",
19
+ "Operating System :: OS Independent",
20
+ "Topic :: Communications :: Chat",
21
+ ]
22
+
23
+ [project.scripts]
24
+ dark-chat = "dark_terminal_chat.client:main"
25
+ dark-chat-server = "dark_terminal_chat.server:main"
26
+
27
+ [project.urls]
28
+ Repository = "https://github.com/iZubayr/dark-terminal-chat"
29
+ Issues = "https://github.com/iZubayr/dark-terminal-chat/issues"
30
+
31
+ [tool.setuptools.packages.find]
32
+ where = ["src"]
@@ -0,0 +1,58 @@
1
+ """AlwaysData supervises this process; it supervises the active chat release."""
2
+ import os
3
+ from pathlib import Path
4
+ import signal
5
+ import subprocess
6
+ import threading
7
+
8
+ ROOT = Path(__file__).resolve().parents[1]
9
+
10
+
11
+ def release_target():
12
+ release = (ROOT / ".deploy/current").resolve(strict=True)
13
+ release.relative_to((ROOT / ".deploy/releases").resolve())
14
+ if not (release / "venv/bin/python").is_file():
15
+ raise ValueError("Active release has no Python runtime")
16
+ return release
17
+
18
+
19
+ def stop_child(child):
20
+ if child.poll() is not None:
21
+ return
22
+ child.terminate()
23
+ try:
24
+ child.wait(timeout=10)
25
+ except subprocess.TimeoutExpired:
26
+ child.kill()
27
+ child.wait()
28
+
29
+
30
+ def main():
31
+ stopped = threading.Event()
32
+ for sig in (signal.SIGINT, signal.SIGTERM):
33
+ signal.signal(sig, lambda *_: stopped.set())
34
+ child = None
35
+ try:
36
+ while not stopped.is_set():
37
+ release = release_target()
38
+ environment = dict(os.environ, PYTHONUNBUFFERED="1")
39
+ child = subprocess.Popen(
40
+ [str(release / "venv/bin/python"), "-m", "dark_terminal_chat.server",
41
+ "--max-clients", "32"], cwd=release, env=environment,
42
+ )
43
+ print(f"Running release: {release.name}", flush=True)
44
+ while not stopped.wait(2):
45
+ status = child.poll()
46
+ if status is not None:
47
+ raise SystemExit(status or 1)
48
+ if release_target() != release:
49
+ break
50
+ stop_child(child)
51
+ child = None
52
+ finally:
53
+ if child is not None:
54
+ stop_child(child)
55
+
56
+
57
+ if __name__ == "__main__":
58
+ main()
@@ -0,0 +1,141 @@
1
+ """Fast-forward the server clone only after CI and a staged install pass."""
2
+ import argparse
3
+ import contextlib
4
+ import io
5
+ import os
6
+ from pathlib import Path
7
+ import re
8
+ import shutil
9
+ import subprocess
10
+ import tarfile
11
+ import venv
12
+
13
+ ROOT = Path(__file__).resolve().parents[1]
14
+
15
+
16
+ def command(*args, cwd=ROOT, capture=False):
17
+ return subprocess.run(args, cwd=cwd, check=True, text=True,
18
+ stdout=subprocess.PIPE if capture else None).stdout
19
+
20
+
21
+ def current_release(root=ROOT):
22
+ current = root / ".deploy/current"
23
+ if not current.exists():
24
+ if current.is_symlink():
25
+ raise ValueError("Active release is missing; cleanup refused")
26
+ return None
27
+ target = current.resolve(strict=True)
28
+ releases = (root / ".deploy/releases").resolve()
29
+ releases.relative_to(root.resolve())
30
+ if target.parent != releases or not re.fullmatch(r"[0-9a-f]{40}", target.name):
31
+ raise ValueError("Invalid active release")
32
+ return target
33
+
34
+
35
+ def activate(release, root=ROOT):
36
+ current = root / ".deploy/current"
37
+ pending = root / ".deploy/current.next"
38
+ with contextlib.suppress(FileNotFoundError):
39
+ pending.unlink()
40
+ pending.symlink_to(release, target_is_directory=True)
41
+ os.replace(pending, current)
42
+
43
+
44
+ def prune_releases(root=ROOT):
45
+ releases = root / ".deploy/releases"
46
+ if releases.is_symlink():
47
+ raise ValueError("Release directory must not be a symlink")
48
+ boundary = releases.resolve()
49
+ boundary.relative_to(root.resolve())
50
+ if not releases.exists():
51
+ return
52
+ active = current_release(root)
53
+ for candidate in releases.iterdir():
54
+ if (candidate.is_symlink() or not candidate.is_dir()
55
+ or not re.fullmatch(r"[0-9a-f]{40}", candidate.name)):
56
+ continue
57
+ resolved = candidate.resolve(strict=True)
58
+ resolved.relative_to(boundary)
59
+ if resolved == active:
60
+ continue
61
+ # These directories contain generated installs, rebuildable from Git.
62
+ shutil.rmtree(resolved)
63
+ print(f"Removed inactive release: {candidate.name}")
64
+
65
+
66
+ def stage(revision, root=ROOT):
67
+ # The free hosting quota cannot retain an unbounded number of environments.
68
+ prune_releases(root)
69
+ release = root / ".deploy/releases" / revision
70
+ release.mkdir(parents=True, exist_ok=True)
71
+ source = release / "code"
72
+ source.mkdir(exist_ok=True)
73
+ archive = subprocess.run(["git", "archive", revision], cwd=root, check=True,
74
+ stdout=subprocess.PIPE).stdout
75
+ with tarfile.open(fileobj=io.BytesIO(archive)) as tree:
76
+ for member in tree.getmembers():
77
+ if member.issym() or member.islnk() or not (member.isfile() or member.isdir()):
78
+ raise ValueError("Unexpected file in release archive")
79
+ (source / member.name).resolve().relative_to(source.resolve())
80
+ if hasattr(tarfile, "data_filter"):
81
+ tree.extractall(source, filter="data")
82
+ else:
83
+ # Older Python 3.10 versions use the path/type checks above.
84
+ tree.extractall(source)
85
+ environment = release / "venv"
86
+ venv.EnvBuilder(with_pip=True).create(environment)
87
+ python = environment / "bin/python"
88
+ # AlwaysData defaults pip to --user; this release has its own environment.
89
+ command(str(python), "-m", "pip", "--isolated", "install", "--no-user", "--no-cache-dir", "--timeout", "120", str(source), cwd=source)
90
+ command(str(python), "-m", "pip", "check", cwd=source)
91
+ command(str(python), "-m", "unittest", "discover", "-s", "tests", "-v", cwd=source)
92
+ return release
93
+
94
+
95
+ def update(root=ROOT):
96
+ if os.name != "posix":
97
+ raise ValueError("Run the updater on the Linux hosting server")
98
+ import fcntl
99
+ state = root / ".deploy"
100
+ state.mkdir(exist_ok=True)
101
+ with (state / "update.lock").open("w") as lock:
102
+ try:
103
+ fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
104
+ except BlockingIOError:
105
+ print("Another update is running.")
106
+ return
107
+ if command("git", "status", "--porcelain", cwd=root, capture=True).strip():
108
+ raise ValueError("Local checkout has edits; update refused")
109
+ origin = command("git", "remote", "get-url", "origin", cwd=root, capture=True).strip()
110
+ match = re.fullmatch(r"https://github\.com/([\w.-]+/[\w.-]+?)(?:\.git)?", origin)
111
+ if not match:
112
+ raise ValueError("Origin must be a public GitHub HTTPS repository")
113
+ # CI alone advances deploy after every test job succeeds. No API token
114
+ # or shared hosting API rate limit is involved in this checkout.
115
+ command("git", "fetch", "origin", "main", "deploy", cwd=root)
116
+ revision = command("git", "rev-parse", "origin/deploy", cwd=root, capture=True).strip()
117
+ if not re.fullmatch(r"[0-9a-f]{40}", revision):
118
+ raise ValueError("Invalid upstream revision")
119
+ command("git", "merge-base", "--is-ancestor", revision, "origin/main", cwd=root)
120
+ previous = current_release(root)
121
+ if previous and previous.name == revision:
122
+ print(f"Already current: {revision}")
123
+ return
124
+ command("git", "merge-base", "--is-ancestor", "HEAD", revision, cwd=root)
125
+ release = stage(revision, root)
126
+ command("git", "merge", "--ff-only", revision, cwd=root)
127
+ activate(release, root)
128
+ print(f"Activated: {revision}")
129
+
130
+
131
+ def main():
132
+ parser = argparse.ArgumentParser(description=__doc__)
133
+ parser.parse_args()
134
+ try:
135
+ update()
136
+ except (OSError, ValueError, subprocess.CalledProcessError) as error:
137
+ raise SystemExit(f"Update failed: {error}") from None
138
+
139
+
140
+ if __name__ == "__main__":
141
+ main()
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+