taskops-cli 0.3.0__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/_wire.py +45 -0
- taskops/cli/commands.py +43 -34
- taskops/cli/enrol.py +82 -0
- taskops/cli/grants.py +59 -0
- taskops/cli/hooks.py +50 -0
- taskops/cli/main.py +42 -91
- taskops/cli/operate.py +57 -40
- taskops/cli/paging.py +75 -0
- taskops/cli/parser.py +149 -0
- taskops/cli/pull.py +162 -0
- taskops/cli/rm.py +63 -0
- taskops/cli/serving.py +14 -16
- taskops/cli/window.py +154 -0
- taskops/core/event.py +17 -7
- taskops/core/forge.py +85 -0
- taskops/core/holding.py +100 -0
- taskops/core/replay.py +1 -1
- taskops/core/reports.py +116 -0
- taskops/core/scope.py +17 -0
- taskops/core/types.py +4 -0
- taskops/gitwork/bind.py +11 -2
- taskops/gitwork/install.py +15 -0
- taskops/gitwork/patch.py +24 -4
- taskops/gitwork/remote.py +14 -1
- taskops/http/admin.py +33 -2
- taskops/http/gitdoor.py +96 -1
- taskops/http/github.py +180 -0
- taskops/http/handler.py +31 -2
- taskops/http/ingest.py +10 -5
- taskops/http/login.py +12 -2
- taskops/http/mounts.py +30 -2
- taskops/http/removal.py +106 -0
- taskops/http/server.py +20 -0
- taskops/http/static.py +30 -0
- taskops/http/upstream.py +1 -1
- taskops/mcp/activity.py +131 -0
- taskops/mcp/dossier.py +14 -4
- taskops/mcp/schema.py +26 -0
- taskops/mcp/tools.py +30 -2
- taskops/ui/app.js +9 -9
- taskops/verbs/__init__.py +10 -0
- taskops/verbs/_facts.py +30 -2
- taskops/verbs/_stories.py +94 -0
- taskops/verbs/activity.py +145 -0
- taskops/verbs/filed.py +114 -0
- taskops/verbs/project.py +53 -7
- taskops/verbs/pulse.py +9 -0
- {taskops_cli-0.3.0.dist-info → taskops_cli-0.3.2.dist-info}/METADATA +114 -10
- {taskops_cli-0.3.0.dist-info → taskops_cli-0.3.2.dist-info}/RECORD +52 -36
- {taskops_cli-0.3.0.dist-info → taskops_cli-0.3.2.dist-info}/WHEEL +0 -0
- {taskops_cli-0.3.0.dist-info → taskops_cli-0.3.2.dist-info}/entry_points.txt +0 -0
taskops/_version.py
CHANGED
taskops/_wire.py
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
"""One POST, one envelope, one place that turns an HTTP failure into OUR error.
|
|
2
|
+
And, since the host learned to ask a forge, one GET that deliberately does NOT
|
|
3
|
+
wear that envelope — `get()` at the bottom says why.
|
|
2
4
|
|
|
3
5
|
{"ok": true, "seq": 41, "data": {...}} -> the data, with `seq` in it
|
|
4
6
|
{"ok": false, "error": {"code", "message"}} -> raised, in the SERVER's words
|
|
@@ -53,6 +55,49 @@ def post(
|
|
|
53
55
|
return _unwrap(envelope, url)
|
|
54
56
|
|
|
55
57
|
|
|
58
|
+
def get(url: str, headers: dict[str, str], timeout: float) -> tuple[int, dict[str, Any]]:
|
|
59
|
+
"""GET JSON from a server that is NOT a taskops one — the status comes back.
|
|
60
|
+
|
|
61
|
+
`post` unwraps `{"ok", "data"}` because both of its callers speak to a
|
|
62
|
+
taskops door. A forge does not: it answers its own shape and says what it
|
|
63
|
+
MEANS in the status — 401 is a bad token, 404 is "no repo you can see" —
|
|
64
|
+
so flattening those into one error would throw away the only thing the
|
|
65
|
+
caller can turn into a sentence somebody can act on.
|
|
66
|
+
|
|
67
|
+
What stays shared is the failure with no status at all: a refused
|
|
68
|
+
connection, a DNS miss, a timeout. That is `Unreachable` here exactly as it
|
|
69
|
+
is above, because the one caller of this function GRANTS on its answer and
|
|
70
|
+
must never be able to read silence as a yes.
|
|
71
|
+
"""
|
|
72
|
+
request = Request(url, headers=headers, method="GET") # noqa: S310 — https, from our config
|
|
73
|
+
try:
|
|
74
|
+
with urlopen(request, timeout=timeout) as answer: # noqa: S310
|
|
75
|
+
return int(answer.status), _decode(answer.read())
|
|
76
|
+
except HTTPError as err:
|
|
77
|
+
return int(err.code), _decode(_body_of(err))
|
|
78
|
+
except (URLError, TimeoutError, ValueError) as err:
|
|
79
|
+
raise Unreachable(
|
|
80
|
+
f"{url} did not answer ({err}). Nothing was granted and nothing was written — "
|
|
81
|
+
"a host that cannot ask does not guess."
|
|
82
|
+
) from err
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _body_of(err: HTTPError) -> bytes:
|
|
86
|
+
try:
|
|
87
|
+
return err.read()
|
|
88
|
+
except OSError: # pragma: no cover — a body that dies mid-read
|
|
89
|
+
return b""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _decode(raw: bytes) -> dict[str, Any]:
|
|
93
|
+
"""A foreign answer that is not an object is not an error here: the STATUS
|
|
94
|
+
already carried the meaning, and the caller reads a field or finds none."""
|
|
95
|
+
try:
|
|
96
|
+
return as_object(json.loads(raw or b"{}"))
|
|
97
|
+
except ValueError:
|
|
98
|
+
return {}
|
|
99
|
+
|
|
100
|
+
|
|
56
101
|
def _unwrap(body: dict[str, Any], url: str) -> dict[str, Any]:
|
|
57
102
|
"""v1 let three verbs answer with a bare array, which the client decoder turned
|
|
58
103
|
into `{}` with no error anywhere. Here a missing envelope is loud."""
|
taskops/cli/commands.py
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
"""The two commands that CONNECT a repo to a board
|
|
1
|
+
"""The two commands that CONNECT a repo to a board: `init` and `join`.
|
|
2
2
|
|
|
3
|
-
`serve`, `invite` and `ui` — the ones that run a server — live in `serving.py
|
|
4
|
-
`
|
|
3
|
+
`serve`, `invite` and `ui` — the ones that run a server — live in `serving.py`,
|
|
4
|
+
and what the git hooks call is `hooks.py`. Both were split off this file rather
|
|
5
|
+
than growing it: these two WRITE a checkout's configuration once, and neither
|
|
6
|
+
runs a server nor fires on every commit. `main.py` only parses and dispatches.
|
|
5
7
|
"""
|
|
6
8
|
|
|
7
9
|
from __future__ import annotations
|
|
@@ -16,7 +18,7 @@ from .._json import query
|
|
|
16
18
|
from ..board import DIR, find_root, open_board
|
|
17
19
|
from ..store import log
|
|
18
20
|
from .._errors import TaskopsError
|
|
19
|
-
from ..gitwork import
|
|
21
|
+
from ..gitwork import remote, install, claudefiles
|
|
20
22
|
|
|
21
23
|
# ── the commands ────────────────────────────────────────────────────────────
|
|
22
24
|
|
|
@@ -24,7 +26,7 @@ from ..gitwork import run, bind, remote, install, trailer, claudefiles
|
|
|
24
26
|
def init(here: Path) -> int:
|
|
25
27
|
root = find_root(here)
|
|
26
28
|
(root / DIR / "board").mkdir(parents=True, exist_ok=True)
|
|
27
|
-
(root
|
|
29
|
+
install.write_local(root)
|
|
28
30
|
_wire(root, actor())
|
|
29
31
|
print(f"local board in {root / DIR / 'board'} — the MCP tools are the way in")
|
|
30
32
|
return 0
|
|
@@ -40,8 +42,21 @@ ORPHAN = (
|
|
|
40
42
|
)
|
|
41
43
|
|
|
42
44
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
+
BOTH = (
|
|
46
|
+
"--invite and --github are two ways to be introduced to the same host, and running "
|
|
47
|
+
"both would burn the invite for nothing. Pick one: --invite <id> if somebody minted "
|
|
48
|
+
"you a link, --github if you have push on the repo the board declared."
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def join( # noqa: PLR0913 — one command, one config; each flag is a way IN
|
|
53
|
+
here: Path,
|
|
54
|
+
target: str,
|
|
55
|
+
given: str,
|
|
56
|
+
key: str = "",
|
|
57
|
+
discard: bool = False,
|
|
58
|
+
invite: str = "",
|
|
59
|
+
github: bool = False,
|
|
45
60
|
) -> int:
|
|
46
61
|
"""Connect this repo to a board. Bare like every other verb since the host is
|
|
47
62
|
recorded and the key is discovered:
|
|
@@ -49,6 +64,7 @@ def join(
|
|
|
49
64
|
taskops remote add https://host:8787 once per checkout
|
|
50
65
|
taskops join my-project registered key: signs in, done
|
|
51
66
|
taskops join my-project --invite <id> first time: enrols the key too
|
|
67
|
+
taskops join my-project --github first time, no invite: GitHub vouches
|
|
52
68
|
taskops join my-project no key anywhere + public board: read-only
|
|
53
69
|
|
|
54
70
|
The name defaults exactly as `board create`'s does (recorded name, else the
|
|
@@ -64,9 +80,13 @@ def join(
|
|
|
64
80
|
archives (never deletes) before proceeding.
|
|
65
81
|
|
|
66
82
|
With an invite, the invite and the PUBKEY travel in the same call: the server
|
|
67
|
-
burns the invite and enrols the key in one act.
|
|
68
|
-
|
|
69
|
-
|
|
83
|
+
burns the invite and enrols the key in one act. `--github` is the same shape
|
|
84
|
+
with a different proof — a token the CLI already has instead of a link
|
|
85
|
+
somebody minted (`enrol.by_github`), and it takes no value on purpose. Either
|
|
86
|
+
way the key then signs in ON THE SPOT and what lands in remote.json is a
|
|
87
|
+
SESSION with an expiry — never a standing token to copy around, and never the
|
|
88
|
+
GitHub one, which this process has already forgotten by the time it writes.
|
|
89
|
+
Keys exist so tokens do not travel.
|
|
70
90
|
"""
|
|
71
91
|
from . import remote as remote_cli
|
|
72
92
|
|
|
@@ -76,9 +96,14 @@ def join(
|
|
|
76
96
|
host, name = remote_cli.named(target)
|
|
77
97
|
target = f"{host}/{name}"
|
|
78
98
|
base = target.partition("?")[0]
|
|
79
|
-
_keep_or_archive(root, base, discard)
|
|
80
99
|
params = query(target)
|
|
81
100
|
invite = invite or params.get("invite", "")
|
|
101
|
+
# Before `_keep_or_archive`, which is the first thing here that MOVES anything:
|
|
102
|
+
# a refusal that had already renamed the local board would be the worse order,
|
|
103
|
+
# and the invite in a pasted `?invite=` link counts exactly as the flag does.
|
|
104
|
+
if invite and github:
|
|
105
|
+
raise TaskopsError(BOTH)
|
|
106
|
+
_keep_or_archive(root, base, discard)
|
|
82
107
|
who = given or actor()
|
|
83
108
|
name = who.partition(":")[2] or "me"
|
|
84
109
|
found = Path(key).expanduser() if key else identity.discover_key()
|
|
@@ -87,6 +112,13 @@ def join(
|
|
|
87
112
|
token, who = enrol.redeem(base, invite, name, enrol.pubkey(str(found) if found else ""))
|
|
88
113
|
if found:
|
|
89
114
|
door = {"host": enrol.host_of(base), "principal": name, "key": str(found)}
|
|
115
|
+
elif github:
|
|
116
|
+
# The door mints NOTHING (`http/github.py`), so there is no token to keep
|
|
117
|
+
# here: the enrolled key is the credential from the next line onwards.
|
|
118
|
+
vouched = enrol.by_github(base, name, enrol.pubkey(str(found) if found else ""))
|
|
119
|
+
who = vouched["actor"]
|
|
120
|
+
door = {"host": enrol.host_of(base), "principal": name, "key": str(found)}
|
|
121
|
+
print(f" GitHub: you have {vouched['need']} on {vouched['repo']} — key enrolled")
|
|
90
122
|
elif bare and found:
|
|
91
123
|
# No invite and no token: the KEY is the whole credential. Proved against
|
|
92
124
|
# the host BEFORE anything is written — a refused sign-in must not leave
|
|
@@ -123,29 +155,6 @@ def _keep_or_archive(root: Path, url: str, discard: bool) -> None:
|
|
|
123
155
|
print(f" the local board ({len(events)} events) is archived at {archive(local)}")
|
|
124
156
|
|
|
125
157
|
|
|
126
|
-
def hook(here: Path, which: str, rest: list[str]) -> int:
|
|
127
|
-
"""The two GIT hooks; `hook claude` is routed in `main` and never prints.
|
|
128
|
-
|
|
129
|
-
Neither of these may block a commit. Failures print and return 0.
|
|
130
|
-
"""
|
|
131
|
-
root = find_root(here)
|
|
132
|
-
if which == "trailer":
|
|
133
|
-
if rest:
|
|
134
|
-
trailer.stamp_file(Path(rest[0]), run.branch_at(here))
|
|
135
|
-
return 0
|
|
136
|
-
facts = bind.commit_facts(here)
|
|
137
|
-
if facts is None:
|
|
138
|
-
return 0
|
|
139
|
-
try:
|
|
140
|
-
board = open_board(root, actor())
|
|
141
|
-
bind.record(board, root, facts)
|
|
142
|
-
bind.drain(board, root)
|
|
143
|
-
except TaskopsError as err:
|
|
144
|
-
print(f"taskops: {err}", file=sys.stderr) # visible, never swallowed
|
|
145
|
-
bind.push_card(here, str(facts["branch"]))
|
|
146
|
-
return 0
|
|
147
|
-
|
|
148
|
-
|
|
149
158
|
# ── plumbing ────────────────────────────────────────────────────────────────
|
|
150
159
|
|
|
151
160
|
|
taskops/cli/enrol.py
CHANGED
|
@@ -4,10 +4,19 @@ Split out of `commands.py` at its own seam — `join` and `board push` both need
|
|
|
4
4
|
exactly this and only this: a repo with a local board has never joined
|
|
5
5
|
anything, so its first push may also be its first introduction to the host,
|
|
6
6
|
and there must not be two redemptions to keep in step.
|
|
7
|
+
|
|
8
|
+
There are two introductions now and they end in the SAME place: a pubkey this
|
|
9
|
+
host knows, and a session minted from it moments later. `redeem` burns an
|
|
10
|
+
invite somebody minted for you; `by_github` (`http/github.py` is the door)
|
|
11
|
+
lets a repo's membership stand in for that invite. Neither leaves a standing
|
|
12
|
+
credential behind, and the GitHub token in particular is a LOCAL — read here,
|
|
13
|
+
put in one request body, and gone with the frame.
|
|
7
14
|
"""
|
|
8
15
|
|
|
9
16
|
from __future__ import annotations
|
|
10
17
|
|
|
18
|
+
import os
|
|
19
|
+
import getpass
|
|
11
20
|
from pathlib import Path
|
|
12
21
|
|
|
13
22
|
from .._wire import post as post_json
|
|
@@ -40,6 +49,79 @@ def pubkey(key: str) -> str:
|
|
|
40
49
|
) from err
|
|
41
50
|
|
|
42
51
|
|
|
52
|
+
NO_KEY = (
|
|
53
|
+
"--github enrols an ssh KEY, and there is none to enrol — GitHub introduces you once "
|
|
54
|
+
"and the key is what signs you in from then on. Make one and try again:\n"
|
|
55
|
+
" ssh-keygen -t ed25519\n"
|
|
56
|
+
"or point at an existing one: taskops join <board> --github --key <path>"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
NO_TOKEN = (
|
|
60
|
+
"no GitHub token, and nothing was sent. Any ONE of these gives me one:\n"
|
|
61
|
+
" gh auth login the CLI's token is read automatically\n"
|
|
62
|
+
" export GITHUB_TOKEN=… an environment variable\n"
|
|
63
|
+
" (or paste it at the hidden prompt)\n"
|
|
64
|
+
"There is deliberately no --github <token>: a token in a flag lands in your shell "
|
|
65
|
+
"history forever, and this one is only ever used for a single call."
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def by_github(base: str, who: str, public: str) -> dict[str, str]:
|
|
70
|
+
"""GitHub vouches for you ONCE, and what stays behind is your pubkey.
|
|
71
|
+
|
|
72
|
+
The token is fetched here, travels in this one request body, and is never
|
|
73
|
+
returned, printed, cached or written: what comes back names the principal
|
|
74
|
+
this host now knows and the repo that proved it, and the caller's very next
|
|
75
|
+
act is the ordinary `/login` challenge with the key just enrolled. That is
|
|
76
|
+
why nothing in this function can put a GitHub token in `remote.json` — it
|
|
77
|
+
has none to put there by the time `join` writes the file.
|
|
78
|
+
"""
|
|
79
|
+
if not public:
|
|
80
|
+
raise TaskopsError(NO_KEY)
|
|
81
|
+
data = post_json(
|
|
82
|
+
f"{base.rstrip('/')}/join/github",
|
|
83
|
+
{"github_token": github_token(), "principal": who, "pubkey": public},
|
|
84
|
+
{},
|
|
85
|
+
20.0,
|
|
86
|
+
)
|
|
87
|
+
return {
|
|
88
|
+
"actor": str(data.get("actor", f"dev:{who}")),
|
|
89
|
+
"repo": str(data.get("repo", "")),
|
|
90
|
+
"need": str(data.get("need", "")),
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def github_token() -> str:
|
|
95
|
+
"""WHERE the token comes from, in order — and never from a flag value.
|
|
96
|
+
|
|
97
|
+
`gh auth token` first, because the CLI is already installed and already
|
|
98
|
+
authenticated on the machine of anybody who has push on a repo, so the
|
|
99
|
+
common case asks the human nothing. Then `$GITHUB_TOKEN`, which is how CI
|
|
100
|
+
and a shell that never installed `gh` say it. Then a HIDDEN prompt.
|
|
101
|
+
|
|
102
|
+
**A flag is not one of the sources and must never become one.** `--github`
|
|
103
|
+
takes no value: a secret passed as an argument is written to
|
|
104
|
+
`~/.zsh_history` by the shell before the process starts, survives every
|
|
105
|
+
rotation of the token, and is visible in `ps` to every user on the box
|
|
106
|
+
while the command runs. The prompt is the fallback precisely because it is
|
|
107
|
+
the one input that leaves no trace.
|
|
108
|
+
"""
|
|
109
|
+
from ..gitwork import run
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
asked = run.tool("gh", "auth", "token", timeout=20.0)
|
|
113
|
+
except TaskopsError:
|
|
114
|
+
asked = None # no `gh` on PATH, or it hung — the next source answers
|
|
115
|
+
if asked is not None and asked.ok and asked.out.strip():
|
|
116
|
+
return asked.out.strip()
|
|
117
|
+
if os.environ.get("GITHUB_TOKEN", "").strip():
|
|
118
|
+
return os.environ["GITHUB_TOKEN"].strip()
|
|
119
|
+
typed = getpass.getpass("GitHub token (input hidden, used once, stored nowhere): ").strip()
|
|
120
|
+
if not typed:
|
|
121
|
+
raise TaskopsError(NO_TOKEN)
|
|
122
|
+
return typed
|
|
123
|
+
|
|
124
|
+
|
|
43
125
|
def host_of(base: str) -> str:
|
|
44
126
|
"""The SERVER, out of a board address: `/login` is server scope, not a board's."""
|
|
45
127
|
return base.rstrip("/").rpartition("/")[0]
|
taskops/cli/grants.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""`taskops invite` · `taskops revoke` — handing a credential out, and taking it back.
|
|
2
|
+
|
|
3
|
+
taskops invite <who> --board <name> owner only — prints the join line
|
|
4
|
+
taskops revoke --key <SHA256:…> | --invite <id>
|
|
5
|
+
|
|
6
|
+
The laptop half of `http/grants.py`, and the split is that seam and not a line
|
|
7
|
+
count: `operate.py` is about a BOARD (create it, list them, say who may read it,
|
|
8
|
+
name the forge that opens it) and this is about a CREDENTIAL — the two never
|
|
9
|
+
share an argument. They do share the transport, which stays in `operate.py`
|
|
10
|
+
where `push.py` already reaches for it; a third module holding three lines of
|
|
11
|
+
`_wire.post` would be one more file to find and nothing to decide in it.
|
|
12
|
+
|
|
13
|
+
**THE BREAK-GLASS PATH SURVIVES** on both verbs: `--root <dir>` runs the same
|
|
14
|
+
act against the files directly, on the machine that holds them, and it is what
|
|
15
|
+
you use when the server is down or the owner's key is lost. Not deprecated,
|
|
16
|
+
never to be removed — a system whose only door is its own API cannot be
|
|
17
|
+
repaired when that API is what broke. Those two acts live in `cli/admin.py`,
|
|
18
|
+
beside `server init`: that module runs ON the box, this one is the laptop.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from . import admin
|
|
27
|
+
from .remote import address
|
|
28
|
+
from .operate import call, signed_in
|
|
29
|
+
from .._errors import TaskopsError
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def invite(args: argparse.Namespace) -> int:
|
|
33
|
+
"""A one-time join line, minted by the server that will honour it."""
|
|
34
|
+
who, name = str(args.who), str(args.board)
|
|
35
|
+
if not who:
|
|
36
|
+
raise TaskopsError("taskops invite <who> --board <name>")
|
|
37
|
+
if args.root: # break-glass: the files, on the box
|
|
38
|
+
return admin.on_box_invite(Path(str(args.root)).expanduser(), who, name)
|
|
39
|
+
if not name:
|
|
40
|
+
raise TaskopsError("which board? taskops invite <who> --board <name>")
|
|
41
|
+
host, _ = address(str(args.host))
|
|
42
|
+
made = call(host, "invite.mint", {"who": who, "board": name}, signed_in(host, args))
|
|
43
|
+
print(f"one-time invite for {who} (id {made['id']}, 7 days):")
|
|
44
|
+
print(f" taskops join \"{host}/{made['board']}?invite={made['token']}\" --key ~/.ssh/id_ed25519")
|
|
45
|
+
return 0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def revoke(args: argparse.Namespace) -> int:
|
|
49
|
+
"""A key stops signing anybody in; an invite stops being redeemable."""
|
|
50
|
+
key, ident = str(args.key), str(args.invite)
|
|
51
|
+
if bool(key) == bool(ident):
|
|
52
|
+
raise TaskopsError("taskops revoke --key <SHA256:…> | --invite <id> — exactly one")
|
|
53
|
+
if args.root: # break-glass: the files, on the box
|
|
54
|
+
return admin.on_box_revoke(Path(str(args.root)).expanduser(), key, ident)
|
|
55
|
+
host, _ = address(str(args.host))
|
|
56
|
+
verb = "key.revoke" if key else "invite.revoke"
|
|
57
|
+
gone = call(host, verb, {"key": key} if key else {"invite": ident}, signed_in(host, args, "sign_key"))
|
|
58
|
+
print(f"revoked {key or ident} ({gone.get('principal') or gone.get('subject')}) on {host}")
|
|
59
|
+
return 0
|
taskops/cli/hooks.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""What the two GIT hooks call — the other half of what `join` installed.
|
|
2
|
+
|
|
3
|
+
Split off `commands.py` at the seam that file's own docstring named ("the two
|
|
4
|
+
commands that CONNECT a repo to a board, **and the git hooks**), the same way
|
|
5
|
+
`serving.py` and `watch.py` were: `init` and `join` WRITE a checkout's
|
|
6
|
+
configuration once, and these two run on every single commit somebody makes in
|
|
7
|
+
it. Nothing here is reachable from a human's fingers — `gitwork/install.py`
|
|
8
|
+
writes the shell scripts that call it, and `main.py` routes them.
|
|
9
|
+
|
|
10
|
+
**Neither of these may ever block a commit.** A failure prints and returns 0.
|
|
11
|
+
The hook is a courtesy that binds a commit to its card; a board that is down,
|
|
12
|
+
a network that is out or a card that was closed a minute ago must cost somebody
|
|
13
|
+
their commit exactly never. That is why the `TaskopsError` below is caught and
|
|
14
|
+
written to stderr instead of raised — visible, and never in the way.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from ..board import find_root, open_board
|
|
23
|
+
from .._errors import TaskopsError
|
|
24
|
+
from ..gitwork import run, bind, trailer
|
|
25
|
+
from .commands import actor
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def hook(here: Path, which: str, rest: list[str]) -> int:
|
|
29
|
+
"""`hook trailer` stamps the message, `hook commit` binds the commit.
|
|
30
|
+
|
|
31
|
+
`hook claude` is a third one and it is routed in `main` instead, because it
|
|
32
|
+
prints NOTHING ever — including the `taskops: …` line every other failure
|
|
33
|
+
here is allowed to write.
|
|
34
|
+
"""
|
|
35
|
+
root = find_root(here)
|
|
36
|
+
if which == "trailer":
|
|
37
|
+
if rest:
|
|
38
|
+
trailer.stamp_file(Path(rest[0]), run.branch_at(here))
|
|
39
|
+
return 0
|
|
40
|
+
facts = bind.commit_facts(here)
|
|
41
|
+
if facts is None:
|
|
42
|
+
return 0
|
|
43
|
+
try:
|
|
44
|
+
board = open_board(root, actor())
|
|
45
|
+
bind.record(board, root, facts)
|
|
46
|
+
bind.drain(board, root)
|
|
47
|
+
except TaskopsError as err:
|
|
48
|
+
print(f"taskops: {err}", file=sys.stderr) # visible, never swallowed
|
|
49
|
+
bind.push_card(here, str(facts["branch"]))
|
|
50
|
+
return 0
|
taskops/cli/main.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""The CLI, which behaves like git: it connects, it never manages.
|
|
2
2
|
|
|
3
3
|
taskops init a local board in this repo
|
|
4
|
-
taskops join <url> join one (?token= or
|
|
4
|
+
taskops join <url> join one (?token=, ?invite= or --github), install the hooks
|
|
5
5
|
taskops remote add <url> the host this checkout operates, like git's origin
|
|
6
6
|
taskops serve host boards — an events API, no dashboard
|
|
7
7
|
taskops server init bootstrap THIS host: its owner and their ssh key
|
|
@@ -9,7 +9,13 @@
|
|
|
9
9
|
with a remote recorded and a key on disk, all of these
|
|
10
10
|
go BARE: no URL, no --key, no board name
|
|
11
11
|
taskops board push THIS repo's local board becomes the hosted one
|
|
12
|
+
taskops board pull the reverse: a hosted board comes down as a SNAPSHOT
|
|
13
|
+
that stops moving — the host keeps everything
|
|
14
|
+
taskops board rm take a board OFF a host — refuses to destroy a history
|
|
15
|
+
this checkout does not hold (--discard-history says so)
|
|
12
16
|
taskops board visibility <host>/<name> public|private owner only
|
|
17
|
+
taskops board forge <owner>/<repo> [--need push|admin] owner only — GitHub opens it
|
|
18
|
+
taskops board forge --clear invite-only again
|
|
13
19
|
taskops invite <who> a single-use link · taskops revoke --key|--invite
|
|
14
20
|
taskops tidy remove worktrees whose work is already in the trunk
|
|
15
21
|
taskops ui the dashboard — serves it if nothing is, opens the browser
|
|
@@ -27,95 +33,29 @@ import argparse
|
|
|
27
33
|
from typing import Sequence
|
|
28
34
|
from pathlib import Path
|
|
29
35
|
|
|
30
|
-
from . import
|
|
36
|
+
from . import (
|
|
37
|
+
rm,
|
|
38
|
+
pull as download,
|
|
39
|
+
push as promote,
|
|
40
|
+
admin,
|
|
41
|
+
hooks,
|
|
42
|
+
claude,
|
|
43
|
+
grants,
|
|
44
|
+
parser as flags,
|
|
45
|
+
remote,
|
|
46
|
+
operate,
|
|
47
|
+
serving,
|
|
48
|
+
commands,
|
|
49
|
+
)
|
|
31
50
|
from ..board import find_root
|
|
32
51
|
from .._errors import TaskopsError
|
|
33
52
|
from ..gitwork import trees
|
|
34
53
|
|
|
35
|
-
AS_HELP = "the principal that key belongs to (default: $USER)"
|
|
36
|
-
|
|
37
54
|
|
|
38
55
|
def main(argv: Sequence[str] | None = None) -> int:
|
|
39
|
-
parser
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
join = sub.add_parser("join", help="join a board and install the hooks")
|
|
43
|
-
join.add_argument(
|
|
44
|
-
"url",
|
|
45
|
-
nargs="?",
|
|
46
|
-
default="",
|
|
47
|
-
help="<name>, <host>/<name>, or nothing (the recorded/directory name) — "
|
|
48
|
-
"a full https:// URL with ?token= or ?invite= keeps working",
|
|
49
|
-
)
|
|
50
|
-
join.add_argument("--as", dest="actor", default="", help="dev:<name> (default: $USER)")
|
|
51
|
-
join.add_argument(
|
|
52
|
-
"--invite",
|
|
53
|
-
default="",
|
|
54
|
-
help="first join: the single-use id from `taskops invite` — your key is enrolled with it",
|
|
55
|
-
)
|
|
56
|
-
join.add_argument(
|
|
57
|
-
"--discard-local",
|
|
58
|
-
action="store_true",
|
|
59
|
-
help="a local board here is archived instead of orphaned by the join",
|
|
60
|
-
)
|
|
61
|
-
join.add_argument(
|
|
62
|
-
"--key",
|
|
63
|
-
default="",
|
|
64
|
-
help="overrides the discovered ssh key (its .pub is what gets registered)",
|
|
65
|
-
)
|
|
66
|
-
origin = sub.add_parser("remote", help="the host this checkout operates (git's origin)")
|
|
67
|
-
origin.add_argument("action", nargs="?", default="", choices=["", "add"])
|
|
68
|
-
origin.add_argument("url", nargs="?", default="", help="https://<host>")
|
|
69
|
-
origin.add_argument(
|
|
70
|
-
"--replace", action="store_true", help="this checkout already names another host"
|
|
71
|
-
)
|
|
72
|
-
server = sub.add_parser("serve", help="host boards")
|
|
73
|
-
server.add_argument("--root", default="~/taskops-boards")
|
|
74
|
-
server.add_argument("--host", default="127.0.0.1")
|
|
75
|
-
server.add_argument("--port", type=int, default=8787)
|
|
76
|
-
host = sub.add_parser("server", help="operate this HOST (over ssh, once): its owner")
|
|
77
|
-
host.add_argument("action", choices=["init"])
|
|
78
|
-
host.add_argument("--root", default="~/taskops-boards")
|
|
79
|
-
host.add_argument("--key", default="", help="the owner's pubkey: a path, or - for stdin")
|
|
80
|
-
host.add_argument("--owner", default="", help="the owner's name (default: $USER)")
|
|
81
|
-
boards = sub.add_parser("board", help="create or list the boards on a host")
|
|
82
|
-
boards.add_argument("action", choices=["create", "ls", "push", "visibility"])
|
|
83
|
-
boards.add_argument("target", nargs="?", default="", help="<host>/<name>, or just <name>")
|
|
84
|
-
boards.add_argument(
|
|
85
|
-
"visibility",
|
|
86
|
-
nargs="?",
|
|
87
|
-
default="",
|
|
88
|
-
choices=["", "public", "private"],
|
|
89
|
-
help="visibility: public means ANONYMOUS READ — writing always needs a key",
|
|
90
|
-
)
|
|
91
|
-
boards.add_argument("--key", default="", help="the ssh key that signs you in")
|
|
92
|
-
boards.add_argument("--invite", default="", help="push: register that key first")
|
|
93
|
-
boards.add_argument("--as", dest="principal", default="", help=AS_HELP)
|
|
94
|
-
invite = sub.add_parser("invite", help="a single-use link for a teammate")
|
|
95
|
-
invite.add_argument("who", nargs="?", default="")
|
|
96
|
-
invite.add_argument("--board", default="")
|
|
97
|
-
invite.add_argument("--host", default="", help="the server (default: the one you joined)")
|
|
98
|
-
invite.add_argument("--key", default="", help="the ssh key that signs you in")
|
|
99
|
-
invite.add_argument("--as", dest="principal", default="", help=AS_HELP)
|
|
100
|
-
# `--root` is BREAK-GLASS and so it has no default: passing it is the deliberate
|
|
101
|
-
# choice to work on the files, on the box, when the API is what broke.
|
|
102
|
-
invite.add_argument("--root", default="", help="break-glass: the boards dir, ON the host")
|
|
103
|
-
kill = sub.add_parser("revoke", help="a key or an invite stops working")
|
|
104
|
-
kill.add_argument("--key", default="", help="a fingerprint: SHA256:…")
|
|
105
|
-
kill.add_argument("--invite", default="", help="a credential id")
|
|
106
|
-
kill.add_argument("--host", default="")
|
|
107
|
-
# NOT `--key`: on this verb that word is already the fingerprint being revoked.
|
|
108
|
-
kill.add_argument("--sign-key", default="", help="the ssh key that signs YOU in")
|
|
109
|
-
kill.add_argument("--as", dest="principal", default="", help=AS_HELP)
|
|
110
|
-
kill.add_argument("--root", default="", help="break-glass: the boards dir, ON the host")
|
|
111
|
-
tidy = sub.add_parser("tidy", help="remove integrated worktrees and branches")
|
|
112
|
-
tidy.add_argument("--trunk", default="")
|
|
113
|
-
sub.add_parser("ui", help="the dashboard: serve if needed, open the browser, token included")
|
|
114
|
-
hook = sub.add_parser("hook", help="internal: what the installed hooks call")
|
|
115
|
-
hook.add_argument("which", choices=["trailer", "commit", "claude"])
|
|
116
|
-
hook.add_argument("rest", nargs="*")
|
|
117
|
-
|
|
118
|
-
args = parser.parse_args(argv)
|
|
56
|
+
"""Parse (`parser.py` holds every flag), then dispatch — and turn the one
|
|
57
|
+
error type the whole program raises into a line and an exit code."""
|
|
58
|
+
args = flags.build(__doc__ or "").parse_args(argv)
|
|
119
59
|
try:
|
|
120
60
|
return _run(args)
|
|
121
61
|
except TaskopsError as err:
|
|
@@ -135,6 +75,7 @@ def _run(args: argparse.Namespace) -> int:
|
|
|
135
75
|
str(args.key),
|
|
136
76
|
bool(args.discard_local),
|
|
137
77
|
str(args.invite),
|
|
78
|
+
bool(args.github),
|
|
138
79
|
)
|
|
139
80
|
if args.command == "remote":
|
|
140
81
|
return remote.remote(args)
|
|
@@ -143,14 +84,24 @@ def _run(args: argparse.Namespace) -> int:
|
|
|
143
84
|
if args.command == "server":
|
|
144
85
|
return admin.server(args)
|
|
145
86
|
if args.command == "board":
|
|
146
|
-
# `push`
|
|
147
|
-
# `board`'s two one-shot calls — so `main` routes
|
|
148
|
-
# the other (
|
|
149
|
-
|
|
87
|
+
# `push` and `pull` are each their own module — five ordered steps and a
|
|
88
|
+
# config flip, against `board`'s two one-shot calls — so `main` routes
|
|
89
|
+
# them, and neither imports the other (both need `operate`'s transport
|
|
90
|
+
# and its address parser, and nothing needs them back). `rm` is separated
|
|
91
|
+
# for the mirrored reason: one call, and a guardrail around it that is
|
|
92
|
+
# the whole command.
|
|
93
|
+
action = str(args.action)
|
|
94
|
+
if action == "push":
|
|
95
|
+
return promote.run(args)
|
|
96
|
+
if action == "pull":
|
|
97
|
+
return download.run(args)
|
|
98
|
+
if action == "rm":
|
|
99
|
+
return rm.run(args)
|
|
100
|
+
return operate.board(args)
|
|
150
101
|
if args.command == "invite":
|
|
151
|
-
return
|
|
102
|
+
return grants.invite(args)
|
|
152
103
|
if args.command == "revoke":
|
|
153
|
-
return
|
|
104
|
+
return grants.revoke(args)
|
|
154
105
|
if args.command == "tidy":
|
|
155
106
|
removed = trees.tidy(find_root(here), str(args.trunk))
|
|
156
107
|
print("\n".join(removed) if removed else "nothing to tidy — no integrated worktrees")
|
|
@@ -162,4 +113,4 @@ def _run(args: argparse.Namespace) -> int:
|
|
|
162
113
|
# own error policy end to end: it prints NOTHING, ever, including the
|
|
163
114
|
# `taskops: …` line `main()` writes for every other failure.
|
|
164
115
|
return claude.deliver(here)
|
|
165
|
-
return
|
|
116
|
+
return hooks.hook(here, str(args.which), [str(x) for x in args.rest])
|