graphban-cli 0.6.0__tar.gz → 0.7.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. {graphban_cli-0.6.0 → graphban_cli-0.7.1}/.gitignore +6 -0
  2. {graphban_cli-0.6.0 → graphban_cli-0.7.1}/PKG-INFO +27 -1
  3. {graphban_cli-0.6.0 → graphban_cli-0.7.1}/README.md +26 -0
  4. {graphban_cli-0.6.0 → graphban_cli-0.7.1}/pyproject.toml +1 -1
  5. {graphban_cli-0.6.0 → graphban_cli-0.7.1}/src/gban/cli.py +87 -2
  6. {graphban_cli-0.6.0 → graphban_cli-0.7.1}/src/gban/client.py +4 -0
  7. graphban_cli-0.7.1/src/gban/data/stack_sdks.toml +177 -0
  8. {graphban_cli-0.6.0 → graphban_cli-0.7.1}/src/gban/skills/graphban-delegation/SKILL.md +7 -0
  9. graphban_cli-0.7.1/src/gban/stackmap.py +1446 -0
  10. {graphban_cli-0.6.0 → graphban_cli-0.7.1}/tests/test_acts.py +3 -2
  11. graphban_cli-0.7.1/tests/test_stackmap.py +720 -0
  12. {graphban_cli-0.6.0 → graphban_cli-0.7.1}/LICENSE +0 -0
  13. {graphban_cli-0.6.0 → graphban_cli-0.7.1}/src/gban/__init__.py +0 -0
  14. {graphban_cli-0.6.0 → graphban_cli-0.7.1}/src/gban/config.py +0 -0
  15. {graphban_cli-0.6.0 → graphban_cli-0.7.1}/src/gban/doctor.py +0 -0
  16. {graphban_cli-0.6.0 → graphban_cli-0.7.1}/src/gban/gitignore.py +0 -0
  17. {graphban_cli-0.6.0 → graphban_cli-0.7.1}/src/gban/setup.py +0 -0
  18. {graphban_cli-0.6.0 → graphban_cli-0.7.1}/src/gban/skills/watch-wave/SKILL.md +0 -0
  19. {graphban_cli-0.6.0 → graphban_cli-0.7.1}/src/gban/swamp.py +0 -0
  20. {graphban_cli-0.6.0 → graphban_cli-0.7.1}/tests/test_doctor.py +0 -0
  21. {graphban_cli-0.6.0 → graphban_cli-0.7.1}/tests/test_login_projects.py +0 -0
  22. {graphban_cli-0.6.0 → graphban_cli-0.7.1}/tests/test_packaging.py +0 -0
  23. {graphban_cli-0.6.0 → graphban_cli-0.7.1}/tests/test_session.py +0 -0
  24. {graphban_cli-0.6.0 → graphban_cli-0.7.1}/tests/test_setup.py +0 -0
  25. {graphban_cli-0.6.0 → graphban_cli-0.7.1}/tests/test_swamp.py +0 -0
@@ -10,6 +10,7 @@ backend/.pytest.db
10
10
  # `test_no_tracked_control_bytes` on main and therefore on every PR built against it.
11
11
  .pytest.db
12
12
  .pytest.db-journal
13
+ *.db-journal
13
14
  # The concurrency lock GRPH-554 added sits beside the database it guards, and xdist gives
14
15
  # each worker its own — so the pattern has to cover `.pytest_gw3.db.lock` as well as the
15
16
  # bare one. Untracked litter in `git status` is how a real change gets missed in the noise.
@@ -31,6 +32,11 @@ web/node_modules/
31
32
  web/dist/
32
33
  web/.vite/
33
34
  web/*.tsbuildinfo
35
+ web/test-results/
36
+ web/playwright-report/
37
+ web/blob-report/
38
+ # Playwright MCP browser session output (console logs, snapshots)
39
+ .playwright-mcp/
34
40
 
35
41
  # Env
36
42
  .env
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: graphban-cli
3
- Version: 0.6.0
3
+ Version: 0.7.1
4
4
  Summary: gban — a Graphban client for the human at a terminal
5
5
  Project-URL: Homepage, https://github.com/asc-me/graphban
6
6
  Project-URL: Repository, https://github.com/asc-me/graphban
@@ -187,6 +187,32 @@ script into a shell. And it never writes the gate key into an MCP config: `gban
187
187
  key must not carry `gate`, or the agent doing the work attests its own completion — which
188
188
  fails silently, since a gate that always says yes looks exactly like a gate that held.
189
189
 
190
+ ## `gban stack map`
191
+
192
+ ```bash
193
+ gban stack map --dry-run
194
+ gban stack map --project <id>
195
+ ```
196
+
197
+ Reads this checkout and posts one app-topology record to `POST /api/platform/stack/app`.
198
+ `--dry-run` prints that record and posts nothing; it needs no server and no key.
199
+ Two runs of the same commit print the same JSON. There is no timestamp in it.
200
+
201
+ What it reads:
202
+
203
+ - Compose files (`compose.yml`, `docker-compose.yml`, and the `compose.*` / `docker-compose.*` variants): a service with `build` is a deployable, a service with only `image` is infrastructure, and `depends_on` is an edge. `environment` contributes variable **names** and, when a value is a literal URL, the bare host.
204
+ - Dockerfiles (`Dockerfile`, `Dockerfile.*`, `*.Dockerfile`), for the deployable they sit in and for a `FROM` image the table knows.
205
+ - Dependency manifests (`package.json`, `pyproject.toml`, `requirements*.txt`, `go.mod`, `Cargo.toml`, `Gemfile`), matched against the known-SDK table and attributed to the deployable whose directory contains them.
206
+ - `.env.example` only. A real `.env` is not a template and is not opened. Source files are not scanned.
207
+
208
+ The known-SDK table ships at `cli/src/gban/data/stack_sdks.toml` and is versioned with `gban`. A checkout may add rows in `.graphban/stack-sdks.toml` (same shape); those rows are consulted first.
209
+
210
+ Compose is parsed by a subset, not by PyYAML: block mappings and lists, single-line `[...]` / `{...}` flow, and `|` / `>` scalars. Anchors and aliases are not expanded (`*name` stays text). A `<<` merge key is ignored. A file the subset cannot read is skipped with a warning on stderr; the rest of the tree is still mapped.
211
+
212
+ Env values are never stored and never posted. A host taken from a URL is the bare host (`api.stripe.com`), because the posted shape rejects any string containing `://`. An image the table does not know is infrastructure named after the **compose service**, kind `unclassified image` — the image string is not stored. A `depends_on`, `external: true`, or `extends.file` that names something this file does not define becomes an `unresolved` node. It is not dropped and it is not a failure.
213
+
214
+ A real post needs a git commit (there is nothing to send for a repo with no HEAD) and a project-write API key: `$GBFLEET_API_KEY`, then `$GRAPHBAN_API_KEY`, then the key `gban setup` stored for this project. The login session is a different credential and is not sent. Pass `--project` (or `$GRAPHBAN_PROJECT`) so the record names the project; without one, `--dry-run` still prints and a post is refused.
215
+
190
216
  ## `gban login` wants a real terminal
191
217
 
192
218
  It refuses without one, rather than prompting. `getpass` falls back to a plain **echoing**
@@ -172,6 +172,32 @@ script into a shell. And it never writes the gate key into an MCP config: `gban
172
172
  key must not carry `gate`, or the agent doing the work attests its own completion — which
173
173
  fails silently, since a gate that always says yes looks exactly like a gate that held.
174
174
 
175
+ ## `gban stack map`
176
+
177
+ ```bash
178
+ gban stack map --dry-run
179
+ gban stack map --project <id>
180
+ ```
181
+
182
+ Reads this checkout and posts one app-topology record to `POST /api/platform/stack/app`.
183
+ `--dry-run` prints that record and posts nothing; it needs no server and no key.
184
+ Two runs of the same commit print the same JSON. There is no timestamp in it.
185
+
186
+ What it reads:
187
+
188
+ - Compose files (`compose.yml`, `docker-compose.yml`, and the `compose.*` / `docker-compose.*` variants): a service with `build` is a deployable, a service with only `image` is infrastructure, and `depends_on` is an edge. `environment` contributes variable **names** and, when a value is a literal URL, the bare host.
189
+ - Dockerfiles (`Dockerfile`, `Dockerfile.*`, `*.Dockerfile`), for the deployable they sit in and for a `FROM` image the table knows.
190
+ - Dependency manifests (`package.json`, `pyproject.toml`, `requirements*.txt`, `go.mod`, `Cargo.toml`, `Gemfile`), matched against the known-SDK table and attributed to the deployable whose directory contains them.
191
+ - `.env.example` only. A real `.env` is not a template and is not opened. Source files are not scanned.
192
+
193
+ The known-SDK table ships at `cli/src/gban/data/stack_sdks.toml` and is versioned with `gban`. A checkout may add rows in `.graphban/stack-sdks.toml` (same shape); those rows are consulted first.
194
+
195
+ Compose is parsed by a subset, not by PyYAML: block mappings and lists, single-line `[...]` / `{...}` flow, and `|` / `>` scalars. Anchors and aliases are not expanded (`*name` stays text). A `<<` merge key is ignored. A file the subset cannot read is skipped with a warning on stderr; the rest of the tree is still mapped.
196
+
197
+ Env values are never stored and never posted. A host taken from a URL is the bare host (`api.stripe.com`), because the posted shape rejects any string containing `://`. An image the table does not know is infrastructure named after the **compose service**, kind `unclassified image` — the image string is not stored. A `depends_on`, `external: true`, or `extends.file` that names something this file does not define becomes an `unresolved` node. It is not dropped and it is not a failure.
198
+
199
+ A real post needs a git commit (there is nothing to send for a repo with no HEAD) and a project-write API key: `$GBFLEET_API_KEY`, then `$GRAPHBAN_API_KEY`, then the key `gban setup` stored for this project. The login session is a different credential and is not sent. Pass `--project` (or `$GRAPHBAN_PROJECT`) so the record names the project; without one, `--dry-run` still prints and a post is refused.
200
+
175
201
  ## `gban login` wants a real terminal
176
202
 
177
203
  It refuses without one, rather than prompting. `getpass` falls back to a plain **echoing**
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "graphban-cli"
3
- version = "0.6.0"
3
+ version = "0.7.1"
4
4
  description = "gban — a Graphban client for the human at a terminal"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -15,7 +15,7 @@ import subprocess
15
15
  import sys
16
16
  from pathlib import Path
17
17
 
18
- from gban import config, doctor as doctor_mod, setup as setup_mod, swamp as swamp_mod
18
+ from gban import config, doctor as doctor_mod, setup as setup_mod, stackmap, swamp as swamp_mod
19
19
  from gban.client import (EXIT_NO_SESSION, EXIT_NO_SUPERVISOR, EXIT_REFUSED, EXIT_UNREACHABLE,
20
20
  Client, NoSession,
21
21
  Refused, Unreachable, authenticated, login)
@@ -91,6 +91,24 @@ def _parser() -> argparse.ArgumentParser:
91
91
  swamp_setup.add_argument("--no-init", action="store_true",
92
92
  help="refuse rather than run `swamp repo init` here")
93
93
 
94
+ stack_cmd = sub.add_parser(
95
+ "stack", help="map this checkout and post the app topology",
96
+ description=("Reads compose files, Dockerfiles, dependency manifests and "
97
+ ".env.example, and posts one app-topology record. Env values "
98
+ "are not stored. --dry-run prints the record and posts nothing."))
99
+ stack_do = stack_cmd.add_subparsers(dest="act")
100
+ stack_map = stack_do.add_parser(
101
+ "map", help="build the record from this repo",
102
+ description=("Deterministic. Two runs on the same commit print the same "
103
+ "JSON. A real post uses a project-write API key "
104
+ f"(${doctor_mod.SUPERVISOR_KEY_ENV} or ${config.API_KEY_ENV}), "
105
+ "never the login session, and goes to "
106
+ f"POST {stackmap._POST_PATH}."))
107
+ stack_map.add_argument("--dry-run", action="store_true",
108
+ help="print the record and do not post it")
109
+ stack_map.add_argument("--repo", default=None,
110
+ help="repository to map (default: the git checkout of the current directory)")
111
+
94
112
  fleet = sub.add_parser(
95
113
  "fleet", help="hand off to gbfleet (the supervisor)",
96
114
  description=("Passes everything through to `gbfleet` and returns its exit code "
@@ -407,6 +425,73 @@ def cmd_swamp(args) -> int:
407
425
  return code
408
426
 
409
427
 
428
+ def cmd_stack(args) -> int:
429
+ """Build the app-topology record and, unless --dry-run, post it (GRPH-P55 §3)."""
430
+ if args.act != "map":
431
+ print(f"{PROG}: usage: {PROG} stack map [--dry-run] [--repo DIR]", file=sys.stderr)
432
+ return EXIT_REFUSED
433
+ start = Path(args.repo).expanduser() if args.repo else Path.cwd()
434
+ project = (config.resolve(args.project, config.PROJECT_ENV, "project") or "").strip()
435
+ try:
436
+ record, warnings = stackmap.build_record(start, project_id=project)
437
+ except stackmap.MapError as exc:
438
+ print(f"{PROG}: {exc}", file=sys.stderr)
439
+ return EXIT_REFUSED
440
+ for warning in warnings:
441
+ print(f"{PROG}: {warning}", file=sys.stderr)
442
+ text = stackmap.canonical(record)
443
+ if args.dry_run:
444
+ sys.stdout.write(text)
445
+ print(f"{PROG}: dry run, nothing posted", file=sys.stderr)
446
+ return 0
447
+ if not project:
448
+ print(f"{PROG}: `{PROG} stack map` needs a project to post. Pass --project, set "
449
+ f"${config.PROJECT_ENV}, or add --dry-run to print the record only.",
450
+ file=sys.stderr)
451
+ return EXIT_REFUSED
452
+ url = config.resolve(args.server, config.URL_ENV, "url")
453
+ if not url:
454
+ print(f"{PROG}: no server. Pass --server, set ${config.URL_ENV}, or run `gban login "
455
+ f"--server …` once.", file=sys.stderr)
456
+ return EXIT_REFUSED
457
+ # The supervisor injects GBFLEET_API_KEY. resolve_supervisor_key does not read
458
+ # it, so a seat that only has that variable would post with nothing.
459
+ key = (os.environ.get(doctor_mod.SUPERVISOR_KEY_ENV) or "").strip()
460
+ if not key:
461
+ key = doctor_mod.resolve_supervisor_key(project, start)
462
+ if not key:
463
+ print(f"{PROG}: no project-write API key. Set ${doctor_mod.SUPERVISOR_KEY_ENV} or "
464
+ f"${config.API_KEY_ENV}, or run `{PROG} setup`. The login session is not a "
465
+ f"key and is not sent.", file=sys.stderr)
466
+ return EXIT_REFUSED
467
+ # The record goes to stdout only after the server took it: a refused post must not
468
+ # leave behind something that reads like a record that was sent (GRPH-1103).
469
+ try:
470
+ receipt = stackmap.post_record(url, key, record)
471
+ except Refused as exc:
472
+ try:
473
+ reasons = json.loads(exc.detail)
474
+ except ValueError:
475
+ reasons = None
476
+ if not isinstance(reasons, list):
477
+ raise
478
+ print(f"{PROG}: the server refused the app map (HTTP {exc.status}):", file=sys.stderr)
479
+ for reason in reasons:
480
+ if isinstance(reason, dict):
481
+ print(f" {reason.get('path', '?')}: {reason.get('reason', '')}",
482
+ file=sys.stderr)
483
+ else:
484
+ print(f" {reason}", file=sys.stderr)
485
+ return EXIT_REFUSED
486
+ sys.stdout.write(text)
487
+ receipt = receipt if isinstance(receipt, dict) else {}
488
+ sha = str(receipt.get("mapped_sha") or record.get("mapped_sha") or "")[:7]
489
+ said = [f"{k} {receipt[k]}" for k in ("mapped_at", "posted_by") if receipt.get(k)]
490
+ print(f"{PROG}: posted {receipt.get('project_id') or project} app map at {sha} to {url}"
491
+ + (f" ({', '.join(said)})" if said else ""), file=sys.stderr)
492
+ return 0
493
+
494
+
410
495
  def cmd_fleet(args) -> int:
411
496
  """A subprocess, never an import (D5).
412
497
 
@@ -560,7 +645,7 @@ def cmd_keys(args) -> int:
560
645
 
561
646
  COMMANDS = {"login": cmd_login, "logout": cmd_logout, "whoami": cmd_whoami,
562
647
  "doctor": cmd_doctor, "setup": cmd_setup, "fleet": cmd_fleet, "seats": cmd_seats,
563
- "agents": cmd_agents, "keys": cmd_keys, "swamp": cmd_swamp}
648
+ "agents": cmd_agents, "keys": cmd_keys, "swamp": cmd_swamp, "stack": cmd_stack}
564
649
 
565
650
 
566
651
  def main(argv: list[str] | None = None) -> int:
@@ -115,6 +115,10 @@ class Client:
115
115
  detail = payload.get("detail") or payload.get("message") or f"HTTP {status}"
116
116
  if isinstance(detail, dict):
117
117
  detail, hint = (detail.get("message") or str(detail)), detail.get("hint", "")
118
+ elif isinstance(detail, list):
119
+ # A list of reasons (e.g. `{path, reason}` from a refused app map) stays JSON,
120
+ # so a caller can read it back rather than parse a Python repr.
121
+ detail, hint = json.dumps(detail), payload.get("hint", "")
118
122
  else:
119
123
  hint = payload.get("hint", "")
120
124
  raise Refused(status, str(detail), str(hint))
@@ -0,0 +1,177 @@
1
+ # Known-SDK table shipped with gban (GRPH-P55 §3). Versioned with the package.
2
+ # A project adds rows in `.graphban/stack-sdks.toml` using the same shape.
3
+ # New shipped rows arrive by PR. cli/tests/test_stackmap.py exercises every row.
4
+ # `domains` on an external row: a URL host equal to or under one of them is that
5
+ # service, so `api.stripe.com` and the `stripe` package draw one node, not two.
6
+
7
+ [[image]]
8
+ id = "postgres"
9
+ names = ["postgres", "pgvector/pgvector"]
10
+ kind = "postgres"
11
+ label = "Postgres"
12
+ protocol = "postgres"
13
+
14
+ [[image]]
15
+ id = "redis"
16
+ names = ["redis", "bitnami/redis"]
17
+ kind = "redis"
18
+ label = "Redis"
19
+ protocol = "redis"
20
+
21
+ [[image]]
22
+ id = "rabbitmq"
23
+ names = ["rabbitmq", "bitnami/rabbitmq"]
24
+ kind = "rabbitmq"
25
+ label = "RabbitMQ"
26
+ protocol = "amqp"
27
+
28
+ [[image]]
29
+ id = "mysql"
30
+ names = ["mysql", "mariadb"]
31
+ kind = "mysql"
32
+ label = "MySQL"
33
+ protocol = "mysql"
34
+
35
+ [[image]]
36
+ id = "mongo"
37
+ names = ["mongo", "mongodb"]
38
+ kind = "mongodb"
39
+ label = "MongoDB"
40
+ protocol = "mongodb"
41
+
42
+ [[sdk]]
43
+ id = "psycopg"
44
+ packages = ["psycopg", "psycopg2", "psycopg2-binary", "psycopg-binary"]
45
+ node = "postgres"
46
+ group = "infrastructure"
47
+ kind = "postgres"
48
+ label = "Postgres"
49
+ protocol = "postgres"
50
+
51
+ [[sdk]]
52
+ id = "pg"
53
+ packages = ["pg", "postgres"]
54
+ node = "postgres"
55
+ group = "infrastructure"
56
+ kind = "postgres"
57
+ label = "Postgres"
58
+ protocol = "postgres"
59
+
60
+ [[sdk]]
61
+ id = "redis"
62
+ packages = ["redis", "ioredis"]
63
+ node = "redis"
64
+ group = "infrastructure"
65
+ kind = "redis"
66
+ label = "Redis"
67
+ protocol = "redis"
68
+
69
+ [[sdk]]
70
+ id = "stripe"
71
+ packages = ["stripe"]
72
+ node = "stripe"
73
+ group = "external"
74
+ kind = ""
75
+ label = "Stripe"
76
+ protocol = "https"
77
+ domains = ["stripe.com"]
78
+
79
+ [[sdk]]
80
+ id = "boto3"
81
+ packages = ["boto3", "botocore"]
82
+ node = "aws"
83
+ group = "external"
84
+ kind = ""
85
+ label = "AWS"
86
+ protocol = "https"
87
+ domains = ["amazonaws.com"]
88
+
89
+ [[sdk]]
90
+ id = "aws-sdk"
91
+ packages = ["aws-sdk"]
92
+ prefix = "@aws-sdk/"
93
+ node = "aws"
94
+ group = "external"
95
+ kind = ""
96
+ label = "AWS"
97
+ protocol = "https"
98
+ domains = ["amazonaws.com"]
99
+
100
+ [[sdk]]
101
+ id = "openai"
102
+ packages = ["openai"]
103
+ node = "openai"
104
+ group = "external"
105
+ kind = ""
106
+ label = "OpenAI"
107
+ protocol = "https"
108
+ domains = ["openai.com"]
109
+
110
+ [[sdk]]
111
+ id = "anthropic"
112
+ packages = ["anthropic", "@anthropic-ai/sdk"]
113
+ node = "anthropic"
114
+ group = "external"
115
+ kind = ""
116
+ label = "Anthropic"
117
+ protocol = "https"
118
+ domains = ["anthropic.com"]
119
+
120
+ [[sdk]]
121
+ id = "sentry"
122
+ packages = ["sentry-sdk", "@sentry/node", "@sentry/browser", "@sentry/react"]
123
+ prefix = "@sentry/"
124
+ node = "sentry"
125
+ group = "external"
126
+ kind = ""
127
+ label = "Sentry"
128
+ protocol = "https"
129
+ domains = ["sentry.io"]
130
+
131
+ [[sdk]]
132
+ id = "twilio"
133
+ packages = ["twilio"]
134
+ node = "twilio"
135
+ group = "external"
136
+ kind = ""
137
+ label = "Twilio"
138
+ protocol = "https"
139
+ domains = ["twilio.com"]
140
+
141
+ [[sdk]]
142
+ id = "sendgrid"
143
+ packages = ["sendgrid", "@sendgrid/mail"]
144
+ node = "sendgrid"
145
+ group = "external"
146
+ kind = ""
147
+ label = "SendGrid"
148
+ protocol = "https"
149
+ domains = ["sendgrid.com", "sendgrid.net"]
150
+
151
+ [[sdk]]
152
+ id = "supabase"
153
+ packages = ["supabase", "@supabase/supabase-js"]
154
+ node = "supabase"
155
+ group = "external"
156
+ kind = ""
157
+ label = "Supabase"
158
+ protocol = "https"
159
+ domains = ["supabase.co", "supabase.com"]
160
+
161
+ [[env]]
162
+ id = "database-url"
163
+ names = ["DATABASE_URL"]
164
+ node = "postgres"
165
+ group = "infrastructure"
166
+ kind = "postgres"
167
+ label = "Postgres"
168
+ protocol = "postgres"
169
+
170
+ [[env]]
171
+ id = "redis-url"
172
+ names = ["REDIS_URL"]
173
+ node = "redis"
174
+ group = "infrastructure"
175
+ kind = "redis"
176
+ label = "Redis"
177
+ protocol = "redis"
@@ -107,6 +107,13 @@ willing to pay for.
107
107
  `seat=true` mints a worker seat **bound to the item**, which is what makes the child claim it
108
108
  at registration rather than racing for it.
109
109
 
110
+ `bound_only=true` (with `seat=true`) is the opt-in that stops the child taking anything else.
111
+ Once its bound item leaves `in_progress`, `claim_review`, `claim_next`, `claim_item`,
112
+ `claim_cluster` and `next_cluster` refuse and name the binding, and the child is told to
113
+ exit. Omit the flag and the child may review other items after it finishes — that is today's
114
+ behaviour, and `gbfleet until` does not set the flag, because a wave relies on finished bound
115
+ workers reviewing each other.
116
+
110
117
  Delegate whole items, never fragments. If it has no title, touchpoints and acceptance, it is
111
118
  not ready to hand over — the touchpoints become the area reservations that stop the child
112
119
  colliding with other work. File it properly or do it inline.