taskops-cli 0.3.1__py3-none-any.whl → 0.3.2__py3-none-any.whl
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.
- taskops/_version.py +1 -1
- taskops/cli/serving.py +1 -1
- taskops/cli/window.py +1 -1
- taskops/gitwork/bind.py +11 -2
- taskops/gitwork/remote.py +14 -1
- taskops/http/gitdoor.py +14 -4
- taskops/http/handler.py +2 -0
- taskops/http/static.py +30 -0
- taskops/http/upstream.py +1 -1
- taskops/ui/app.js +8 -8
- {taskops_cli-0.3.1.dist-info → taskops_cli-0.3.2.dist-info}/METADATA +1 -1
- {taskops_cli-0.3.1.dist-info → taskops_cli-0.3.2.dist-info}/RECORD +14 -14
- {taskops_cli-0.3.1.dist-info → taskops_cli-0.3.2.dist-info}/WHEEL +0 -0
- {taskops_cli-0.3.1.dist-info → taskops_cli-0.3.2.dist-info}/entry_points.txt +0 -0
taskops/_version.py
CHANGED
taskops/cli/serving.py
CHANGED
|
@@ -110,7 +110,7 @@ def ui(here: Path) -> int:
|
|
|
110
110
|
port = httpd.server_address[1]
|
|
111
111
|
(root / DIR / "ui.json").write_text(json.dumps({"port": port, "token": token}) + "\n")
|
|
112
112
|
window.retire_when_idle(httpd.mounts, httpd)
|
|
113
|
-
_open(f"http://127.0.0.1:{port}
|
|
113
|
+
_open(f"http://127.0.0.1:{port}/?token={token}")
|
|
114
114
|
try:
|
|
115
115
|
httpd.serve_forever()
|
|
116
116
|
except KeyboardInterrupt:
|
taskops/cli/window.py
CHANGED
|
@@ -104,7 +104,7 @@ def holder(folder: Path, checkout: Path) -> str:
|
|
|
104
104
|
f"ctrl-c in its terminal, or wait ~{int(IDLE_SECONDS / 60)} minutes: "
|
|
105
105
|
"a window with no open tab retires itself"
|
|
106
106
|
)
|
|
107
|
-
return f"http://127.0.0.1:{port}
|
|
107
|
+
return f"http://127.0.0.1:{port}/?token={token}"
|
|
108
108
|
raise TaskopsError(
|
|
109
109
|
f"something holds {folder / LOCK} but no window answers on port {port or '?'} — "
|
|
110
110
|
"it may be starting (run this again) or wedged: find it with "
|
taskops/gitwork/bind.py
CHANGED
|
@@ -144,7 +144,16 @@ def drain(board: Caller, repo: Path) -> int:
|
|
|
144
144
|
|
|
145
145
|
|
|
146
146
|
def push_card(repo: Path, branch: str) -> bool:
|
|
147
|
-
"""Publish the card's branch so a PR (or the orchestrator) can see it.
|
|
147
|
+
"""Publish the card's branch so a PR (or the orchestrator) can see it.
|
|
148
|
+
|
|
149
|
+
BOTH sides of the refspec, for the reason `remote.push` carries in full: a
|
|
150
|
+
one-sided `origin <branch>` is resolved through `push.default`, and under
|
|
151
|
+
`upstream` that is the tracked branch and not this one. Here the tracking is
|
|
152
|
+
also what `--set-upstream` is about to write, so the wrong destination and
|
|
153
|
+
the record of it would agree with each other.
|
|
154
|
+
"""
|
|
148
155
|
if not trailer.card_of(branch):
|
|
149
156
|
return False
|
|
150
|
-
return run.git(
|
|
157
|
+
return run.git(
|
|
158
|
+
"push", "--set-upstream", "origin", f"{branch}:refs/heads/{branch}", cwd=repo
|
|
159
|
+
).ok
|
taskops/gitwork/remote.py
CHANGED
|
@@ -142,6 +142,19 @@ def push(repo: Path, *branches: str, cwd: Path | None = None) -> None:
|
|
|
142
142
|
return
|
|
143
143
|
for name in names:
|
|
144
144
|
try:
|
|
145
|
-
|
|
145
|
+
# BOTH sides of the refspec, always. `git push origin <name>` looks
|
|
146
|
+
# explicit and is not: with no colon, git resolves the destination
|
|
147
|
+
# through `push.default`, and under `upstream` — a setting a human
|
|
148
|
+
# may have in ~/.gitconfig for their own reasons — that destination
|
|
149
|
+
# is the branch's UPSTREAM, not its name. Measured here on
|
|
150
|
+
# 2026-08-10: milestone branches cut in v1 still tracked
|
|
151
|
+
# `origin/main`, so this best-effort visibility push was writing
|
|
152
|
+
# integration branches onto MAIN, in a function that swallows every
|
|
153
|
+
# error, in a repo whose whole point is that only the human lands
|
|
154
|
+
# anything on the trunk. `<name>:refs/heads/<name>` cannot be
|
|
155
|
+
# reinterpreted by any config.
|
|
156
|
+
run.git(
|
|
157
|
+
"push", "origin", f"{name}:refs/heads/{name}", cwd=where, timeout=PUSH_TIMEOUT
|
|
158
|
+
)
|
|
146
159
|
except TaskopsError: # timeout, or no git at all — never the caller's problem
|
|
147
160
|
return
|
taskops/http/gitdoor.py
CHANGED
|
@@ -94,6 +94,7 @@ ABSENT = (
|
|
|
94
94
|
|
|
95
95
|
HTML = "text/html"
|
|
96
96
|
TEXT = "text/plain"
|
|
97
|
+
MARKDOWN = "text/markdown"
|
|
97
98
|
|
|
98
99
|
|
|
99
100
|
def answer(repo: Path | None, tail: str, query: str) -> dict[str, Any]:
|
|
@@ -135,9 +136,8 @@ def _file(repo: Path, rev: str, wanted: str) -> dict[str, Any]:
|
|
|
135
136
|
reading a report is the UI reading a patch, and a second vocabulary would be
|
|
136
137
|
a second renderer. `content_type` is a FIELD, never this response's header —
|
|
137
138
|
every door answers `application/json`, so no path can make THIS origin, the
|
|
138
|
-
one the token lives in, serve HTML; the reader sandboxes it.
|
|
139
|
-
|
|
140
|
-
degrade to something no renderer will execute."""
|
|
139
|
+
one the token lives in, serve HTML; the reader sandboxes it. The three types
|
|
140
|
+
`_kind` answers are argued there."""
|
|
141
141
|
path = reports.under(wanted)
|
|
142
142
|
if not path:
|
|
143
143
|
raise BadRequest(NOT_A_REPORT.format(path=wanted or "a missing ?path="))
|
|
@@ -153,7 +153,7 @@ def _file(repo: Path, rev: str, wanted: str) -> dict[str, Any]:
|
|
|
153
153
|
return {
|
|
154
154
|
"path": path,
|
|
155
155
|
"rev": sha,
|
|
156
|
-
"content_type":
|
|
156
|
+
"content_type": _kind(path),
|
|
157
157
|
"text": text,
|
|
158
158
|
"truncated": cut,
|
|
159
159
|
"cap": patch.CAP,
|
|
@@ -186,3 +186,13 @@ def _param(query: str, key: str) -> str:
|
|
|
186
186
|
if name == key:
|
|
187
187
|
return unquote(value.replace("+", " "))
|
|
188
188
|
return ""
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _kind(path: str) -> str:
|
|
192
|
+
"""The three answers, and the default keeps itself safe: an unrecognised
|
|
193
|
+
type falls to `text/plain`, which every reader draws as characters. Decided
|
|
194
|
+
HERE and never in the browser — `ReportFrame` reads what the door SAYS a
|
|
195
|
+
file is, never the extension it can see, so the two cannot drift apart."""
|
|
196
|
+
if path.endswith(".html"):
|
|
197
|
+
return HTML
|
|
198
|
+
return MARKDOWN if path.endswith(".md") else TEXT
|
taskops/http/handler.py
CHANGED
|
@@ -70,6 +70,8 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
70
70
|
self._git(board, tail[4:])
|
|
71
71
|
elif tail.startswith("ui"):
|
|
72
72
|
self._static(tail[2:])
|
|
73
|
+
elif (page := static.at_root(self.mounts.ui, self.path)) is not None:
|
|
74
|
+
self._send(200, *page) # a WINDOW serves its page at the ROOT
|
|
73
75
|
else:
|
|
74
76
|
self._fail(404, BadRequest(f"nothing at {self.path}"))
|
|
75
77
|
|
taskops/http/static.py
CHANGED
|
@@ -84,6 +84,36 @@ def payload(root: Path, rest: str) -> tuple[bytes, str] | None:
|
|
|
84
84
|
return (path.read_bytes(), content_type(path)) if path else None
|
|
85
85
|
|
|
86
86
|
|
|
87
|
+
def at_root(root: Path | None, path: str) -> tuple[bytes, str] | None:
|
|
88
|
+
"""The bundle served at the ROOT of a window — `/`, `/app.js`, `/style.css`.
|
|
89
|
+
|
|
90
|
+
`taskops ui` used to hand out `/board/ui/?token=…`, and that path is an
|
|
91
|
+
implementation detail leaking into the one URL a human types: `board` is
|
|
92
|
+
just the name a window mounts its single board under, and `/ui/` was the
|
|
93
|
+
door on a HOST that no longer serves a page at all. A window is a window;
|
|
94
|
+
its address is its port.
|
|
95
|
+
|
|
96
|
+
Deliberately NOT `resolve`: that one falls back to the index for any
|
|
97
|
+
unknown tail, which is right for a door mounted under `/<board>/ui/` and
|
|
98
|
+
wrong at the root, where it would swallow every mistyped API path and
|
|
99
|
+
answer a mistake with a page. Only real files, plus `/` for the index — so
|
|
100
|
+
`/nope` is still the honest 404 the API gives.
|
|
101
|
+
|
|
102
|
+
`root is None` IS the host switch and the only one there is: `Mounts` sets
|
|
103
|
+
`ui` from the same `repo is not None` that /git and /healthz's identity
|
|
104
|
+
read, so a board host cannot start serving a dashboard by any route,
|
|
105
|
+
including this one — there is no second condition to keep in step.
|
|
106
|
+
"""
|
|
107
|
+
if root is None or not root.is_dir():
|
|
108
|
+
return None
|
|
109
|
+
rest = path.partition("?")[0].strip("/")
|
|
110
|
+
base = root.resolve()
|
|
111
|
+
target = (root / INDEX) if not rest else (root / rest).resolve()
|
|
112
|
+
if base != target and base not in target.parents:
|
|
113
|
+
return None # `..%2f..` and symlinks have already collapsed
|
|
114
|
+
return (target.read_bytes(), content_type(target)) if target.is_file() else None
|
|
115
|
+
|
|
116
|
+
|
|
87
117
|
def answer(root: Path | None, rest: str) -> tuple[int, bytes, str]:
|
|
88
118
|
"""The WHOLE /ui door: (status, body, content-type). `root is None` means
|
|
89
119
|
this process is a board host — see the module docstring for why that is a
|
taskops/http/upstream.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""The remote board a LOCAL window is a window onto — the one thing that differs.
|
|
2
2
|
|
|
3
3
|
`taskops ui` always serves the dashboard from the checkout it stands in. The
|
|
4
|
-
routes are the same in both modes (
|
|
4
|
+
routes are the same in both modes (`/` for the page, `/board/rpc`, `/board/git/…`),
|
|
5
5
|
so the committed bundle never learns which kind of board it is talking to. The
|
|
6
6
|
whole difference is who answers `/rpc`:
|
|
7
7
|
|