geodeploy 1.3.0__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.
@@ -0,0 +1,317 @@
1
+ """`geodeploy admin …` and `geodeploy users …` — operating an instance.
2
+
3
+ Everything under `admin` needs a **password session** (`geodeploy login --password`), because the
4
+ API rejects API tokens on those routes so that a leaked token cannot restart your database or read
5
+ your storage credentials. `users` is different: it is scope-gated, so a token with `users:admin`
6
+ works.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from ...errors import GeoDeployError
11
+ from ..main import add_command, group_parser
12
+ from ..output import EXIT_GENERIC, EXIT_OK, human_size
13
+ from ._common import confirm
14
+
15
+
16
+ def register(subparsers) -> None:
17
+ group = group_parser(subparsers, "admin", "health, services, updates, backups, activity log")
18
+
19
+ add_command(group, "health", cmd_health, "status of every service in the stack")
20
+
21
+ services = add_command(group, "service", cmd_service, "start, stop or restart a service")
22
+ services.add_argument("name", help="postgres | minio | redis | martin | titiler | nginx | "
23
+ "celery | ui")
24
+ services.add_argument("action", choices=["start", "stop", "restart"])
25
+ services.add_argument("--yes", action="store_true")
26
+
27
+ logs = add_command(group, "logs", cmd_logs, "recent log lines from one service")
28
+ logs.add_argument("name")
29
+ logs.add_argument("-n", "--tail", type=int, default=200)
30
+ logs.add_argument("--no-timestamps", action="store_true")
31
+
32
+ add_command(group, "storage", cmd_storage, "how much space each store is using")
33
+
34
+ updates = add_command(group, "updates", cmd_updates,
35
+ "what this instance could update to (main, releases, branches)")
36
+ updates.add_argument("--refresh", action="store_true",
37
+ help="bypass the 10-minute cache and ask GitHub now")
38
+
39
+ update = add_command(group, "update", cmd_update, "update the instance")
40
+ update.add_argument("target", nargs="?", help="main, a release tag, or a branch "
41
+ "(default: whatever the instance follows)")
42
+ update.add_argument("--yes", action="store_true")
43
+ update.add_argument("--watch", action="store_true", help="follow the update to completion")
44
+
45
+ add_command(group, "reload-martin", cmd_reload_martin,
46
+ "regenerate the vector tile server's config from the ready PostGIS layers")
47
+
48
+ audit = add_command(group, "audit", cmd_audit, "the activity log (paginated, filtered server-side)")
49
+ audit.add_argument("-n", "--limit", type=int, default=20)
50
+ audit.add_argument("--offset", type=int, default=0)
51
+ audit.add_argument("--action", help="exact action, or a family prefix like 'portal'")
52
+ audit.add_argument("--resource-type")
53
+ audit.add_argument("--resource-id")
54
+ audit.add_argument("--actor", type=int, dest="actor_id")
55
+ audit.add_argument("--query", dest="search")
56
+ audit.add_argument("--since", help="ISO instant, e.g. 2026-08-01T00:00:00Z")
57
+ audit.add_argument("--until")
58
+
59
+ backups = add_command(group, "backups", cmd_backups, "backup history and destination status")
60
+ backups.add_argument("--stored", action="store_true",
61
+ help="list what is actually AT the destination, not our own log")
62
+ backups.add_argument("--run", action="store_true", help="start a backup now")
63
+ backups.add_argument("-n", "--limit", type=int, default=10)
64
+
65
+ listing = add_command(group, "public-index", cmd_public_index,
66
+ "show or change whether this instance is publicly browsable",
67
+ epilog="""examples:
68
+ geodeploy admin public-index is it listed?
69
+ geodeploy admin public-index --off stop listing (links keep working)
70
+ geodeploy admin public-index --on
71
+
72
+ Listing controls whether anyone can DISCOVER your published public portals and public layers from
73
+ the instance URL — what `geodeploy browse` and the QGIS plugin read. It does not change who may
74
+ open them: a public portal stays reachable by its link either way.
75
+ """)
76
+ state = listing.add_mutually_exclusive_group()
77
+ state.add_argument("--on", dest="enabled", action="store_true", default=None)
78
+ state.add_argument("--off", dest="enabled", action="store_false", default=None)
79
+
80
+ add_command(group, "credentials", cmd_credentials,
81
+ "connection details for the managed PostGIS and MinIO (owner only, audited)")
82
+
83
+ # ── users ───────────────────────────────────────────────────────────────────────────────────
84
+ users = group_parser(subparsers, "users", "members, roles and invitations")
85
+ add_command(users, "list", cmd_users_list, "list members", aliases=["ls"])
86
+ invite = add_command(users, "invite", cmd_users_invite, "invite someone (returns a link once)")
87
+ invite.add_argument("email")
88
+ invite.add_argument("--role", choices=["viewer", "editor", "admin"], default="viewer")
89
+ add_command(users, "invitations", cmd_users_invitations, "pending invitations")
90
+ role = add_command(users, "role", cmd_users_role, "change a member's role")
91
+ role.add_argument("user_id", type=int)
92
+ role.add_argument("role", choices=["viewer", "editor", "admin"])
93
+ remove = add_command(users, "remove", cmd_users_remove,
94
+ "remove a member (their data is reassigned to the owner)", aliases=["rm"])
95
+ remove.add_argument("user_id", type=int)
96
+ remove.add_argument("--yes", action="store_true")
97
+
98
+
99
+ def _admin(ctx):
100
+ return ctx.client(session=True)
101
+
102
+
103
+ # ── health & services ────────────────────────────────────────────────────────────────────────────
104
+
105
+ def cmd_health(ctx, args) -> int:
106
+ rows = _admin(ctx).admin.health()
107
+ ctx.out.render(rows, ["name", "status", "controllable", "message"])
108
+ if not ctx.out.json_mode:
109
+ bad = [r for r in rows if (r.get("status") or "") not in ("healthy", "running")]
110
+ if bad:
111
+ ctx.out.warn("{0} service(s) not healthy: {1}".format(
112
+ len(bad), ", ".join(r.get("name") for r in bad)))
113
+ return EXIT_OK
114
+
115
+
116
+ def cmd_service(ctx, args) -> int:
117
+ if args.action in ("stop", "restart") and not confirm(
118
+ ctx.out, "{0} the {1} service?".format(args.action.capitalize(), args.name), args.yes):
119
+ return EXIT_GENERIC
120
+ ctx.out.render(_admin(ctx).admin.service(args.name, args.action))
121
+ if not ctx.out.json_mode:
122
+ ctx.out.success("{0} {1}ed.".format(args.name, args.action.rstrip("e")))
123
+ return EXIT_OK
124
+
125
+
126
+ def cmd_logs(ctx, args) -> int:
127
+ data = _admin(ctx).admin.logs(args.name, tail=args.tail, timestamps=not args.no_timestamps)
128
+ if ctx.out.json_mode:
129
+ ctx.out.json(data)
130
+ return EXIT_OK
131
+ text = data.get("logs") if isinstance(data, dict) else data
132
+ ctx.out.out(text if isinstance(text, str) else str(text))
133
+ return EXIT_OK
134
+
135
+
136
+ def cmd_storage(ctx, args) -> int:
137
+ stats = _admin(ctx).admin.storage_stats()
138
+ if ctx.out.json_mode:
139
+ ctx.out.json(stats)
140
+ return EXIT_OK
141
+ rows = []
142
+ for key, label in (("postgis_bytes", "PostGIS tables"), ("raster_bytes", "Raster COGs"),
143
+ ("geoparquet_bytes", "GeoParquet + PMTiles"),
144
+ ("portal_bundle_bytes", "Published portals"), ("used_bytes", "Total")):
145
+ value = stats.get(key)
146
+ # None means "could not be measured", which is NOT zero — saying 0 for an unreachable
147
+ # database would read as "empty" and send someone looking for missing data.
148
+ rows.append({"store": label,
149
+ "size": human_size(value) if value is not None else "not measured"})
150
+ ctx.out.table(rows, ["store", "size"])
151
+ ctx.out.info("{0} vector layers, {1} rasters, {2} portals.".format(
152
+ stats.get("vector_layers"), stats.get("raster_layers"), stats.get("portals")))
153
+ return EXIT_OK
154
+
155
+
156
+ # ── updates ──────────────────────────────────────────────────────────────────────────────────────
157
+
158
+ def cmd_updates(ctx, args) -> int:
159
+ data = _admin(ctx).admin.updates(refresh=args.refresh)
160
+ if ctx.out.json_mode:
161
+ ctx.out.json(data)
162
+ return EXIT_OK
163
+ main = data.get("main") or {}
164
+ ctx.out.record({"channel": data.get("channel"), "current": data.get("current_ref"),
165
+ "main": main.get("status"), "commits behind": main.get("commits"),
166
+ "latest release": (data.get("latest_release") or {}).get("tag")},
167
+ ["channel", "current", "main", "commits behind", "latest release"])
168
+ releases = data.get("releases") or []
169
+ if releases:
170
+ ctx.out.out("")
171
+ ctx.out.table([{"tag": r.get("tag"), "published": r.get("published_at"),
172
+ "title": r.get("name")} for r in releases[:10]],
173
+ ["tag", "published", "title"])
174
+ return EXIT_OK
175
+
176
+
177
+ def cmd_update(ctx, args) -> int:
178
+ client = _admin(ctx)
179
+ preflight = client.admin.preflight()
180
+ if not ctx.out.json_mode and preflight:
181
+ ctx.out.record(preflight)
182
+ if preflight.get("blocked") or preflight.get("ok") is False:
183
+ ctx.out.error(preflight.get("reason") or "The instance refused: work is in progress.")
184
+ return EXIT_GENERIC
185
+ if not confirm(ctx.out, "Update this instance to {0}?".format(args.target or "its channel"),
186
+ args.yes):
187
+ return EXIT_GENERIC
188
+ started = client.admin.update(args.target)
189
+ ctx.out.render(started)
190
+ if args.watch:
191
+ import time
192
+ while True:
193
+ time.sleep(4)
194
+ try:
195
+ status = client.admin.update_status()
196
+ except GeoDeployError as exc:
197
+ # Expected: the API container is recreated BY the update, so the poll that hits
198
+ # that moment fails. That is progress, not an error.
199
+ ctx.out.info(" (instance restarting: {0})".format(exc))
200
+ continue
201
+ phase = status.get("phase") or status.get("status")
202
+ ctx.out.info(" {0}".format(phase))
203
+ if phase in ("done", "complete", "completed", "error", "failed"):
204
+ ctx.out.render(status)
205
+ return EXIT_OK if phase.startswith(("done", "complete")) else EXIT_GENERIC
206
+ if not ctx.out.json_mode:
207
+ ctx.out.success("Update started. `geodeploy admin updates` will show the new version once "
208
+ "the API comes back.")
209
+ return EXIT_OK
210
+
211
+
212
+ def cmd_reload_martin(ctx, args) -> int:
213
+ ctx.out.render(_admin(ctx).admin.reload_martin())
214
+ return EXIT_OK
215
+
216
+
217
+ # ── audit & backups ──────────────────────────────────────────────────────────────────────────────
218
+
219
+ def cmd_audit(ctx, args) -> int:
220
+ page = _admin(ctx).admin.audit(limit=args.limit, offset=args.offset, action=args.action,
221
+ resource_type=args.resource_type, resource_id=args.resource_id,
222
+ actor_id=args.actor_id, query=args.search, since=args.since,
223
+ until=args.until)
224
+ if ctx.out.json_mode:
225
+ ctx.out.json(page)
226
+ return EXIT_OK
227
+ ctx.out.table(page.get("items") or [],
228
+ ["created_at", "actor_name", "action", "resource_type", "resource_id"])
229
+ ctx.out.info("{0}-{1} of {2}".format(page.get("offset", 0) + 1,
230
+ page.get("offset", 0) + len(page.get("items") or []),
231
+ page.get("total")))
232
+ return EXIT_OK
233
+
234
+
235
+ def cmd_backups(ctx, args) -> int:
236
+ client = _admin(ctx)
237
+ if args.run:
238
+ ctx.out.render(client.admin.backup_run())
239
+ if not ctx.out.json_mode:
240
+ ctx.out.success("Backup started — it runs in the worker, so this returns immediately.")
241
+ return EXIT_OK
242
+ if args.stored:
243
+ ctx.out.render(client.admin.backup_stored(), ["key", "size", "created_at"],
244
+ empty="Nothing at the destination.")
245
+ return EXIT_OK
246
+ settings = client.admin.backup_settings()
247
+ if not ctx.out.json_mode:
248
+ ctx.out.record(settings, ["enabled", "schedule", "hour", "keep", "bucket", "prefix",
249
+ "endpoint", "include_postgis", "include_objects",
250
+ "include_state"])
251
+ ctx.out.out("")
252
+ ctx.out.render(client.admin.backup_runs(limit=args.limit),
253
+ ["id", "started_at", "status", "trigger", "size_bytes", "error_message"],
254
+ empty="No backup runs recorded.")
255
+ return EXIT_OK
256
+
257
+
258
+ def cmd_public_index(ctx, args) -> int:
259
+ client = _admin(ctx)
260
+ if args.enabled is None:
261
+ state = client.admin.public_index()
262
+ else:
263
+ state = client.admin.set_public_index(args.enabled)
264
+ ctx.out.render(state, ["enabled"])
265
+ if not ctx.out.json_mode:
266
+ if state.get("enabled"):
267
+ ctx.out.success("This instance is listed — `geodeploy browse {0}` shows what anyone "
268
+ "can see.".format(ctx.resolved.url))
269
+ else:
270
+ ctx.out.success("Not listed. Published public portals stay reachable by their links; "
271
+ "they are simply not enumerated.")
272
+ return EXIT_OK
273
+
274
+
275
+ def cmd_credentials(ctx, args) -> int:
276
+ ctx.out.render(_admin(ctx).admin.credentials())
277
+ return EXIT_OK
278
+
279
+
280
+ # ── users ────────────────────────────────────────────────────────────────────────────────────────
281
+
282
+ def cmd_users_list(ctx, args) -> int:
283
+ ctx.out.render(ctx.client().users.list(),
284
+ ["id", "name", "email", "role", "vector_count", "raster_count", "portal_count",
285
+ "created_at"])
286
+ return EXIT_OK
287
+
288
+
289
+ def cmd_users_invite(ctx, args) -> int:
290
+ invite = ctx.client().users.invite(args.email, args.role)
291
+ ctx.out.render(invite, ["id", "email", "role", "expires_at", "invite_url", "token",
292
+ "email_sent"])
293
+ if not ctx.out.json_mode:
294
+ ctx.out.warn("The invitation link is shown once — regenerating is the only way to get it "
295
+ "again.")
296
+ return EXIT_OK
297
+
298
+
299
+ def cmd_users_invitations(ctx, args) -> int:
300
+ ctx.out.render(ctx.client().users.invitations(),
301
+ ["id", "email", "role", "purpose", "expires_at", "created_at"],
302
+ empty="No pending invitations.")
303
+ return EXIT_OK
304
+
305
+
306
+ def cmd_users_role(ctx, args) -> int:
307
+ ctx.out.render(ctx.client().users.set_role(args.user_id, args.role), ["id", "email", "role"])
308
+ return EXIT_OK
309
+
310
+
311
+ def cmd_users_remove(ctx, args) -> int:
312
+ if not confirm(ctx.out, "Remove user {0}? Their layers and portals move to the owner.".format(
313
+ args.user_id), args.yes):
314
+ return EXIT_GENERIC
315
+ ctx.client().users.delete(args.user_id)
316
+ ctx.out.render({"ok": True, "removed": args.user_id})
317
+ return EXIT_OK
@@ -0,0 +1,280 @@
1
+ """login / logout / whoami / profile / token — who you are, and against which instance.
2
+
3
+ **An API token is the normal credential.** `geodeploy login <url> --token gdp_…` (or the
4
+ `GEODEPLOY_TOKEN` environment variable) is what scripts, CI and the QGIS plugin use, and it is all
5
+ the CLI needs for data, portals and publishing.
6
+
7
+ `login --password` exists for one reason: the API rejects API tokens on `/admin/*`, `/backups/*`,
8
+ `/tokens` and ownership transfer, so that a leaked token cannot reconfigure an instance or mint
9
+ more tokens. Those commands therefore need a *session*. The password is never stored and never
10
+ accepted as a flag value — it is prompted for (or read from stdin, for CI), exchanged once for the
11
+ same 7-day JWT the browser gets, and only that is kept.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import getpass
16
+ import sys
17
+ from typing import Any
18
+
19
+ from ... import config as cfg
20
+ from ...errors import AuthError, ConfigError
21
+ from ..main import add_command, group_parser
22
+ from ..output import EXIT_AUTH, EXIT_GENERIC, EXIT_OK, EXIT_USAGE
23
+
24
+
25
+ def register(subparsers) -> None:
26
+ login = add_command(
27
+ subparsers, "login", cmd_login, "log in to an instance and remember it",
28
+ epilog="""\
29
+ examples:
30
+ geodeploy login https://gd.example.org --token gdp_xxx # normal: a scoped API token
31
+ geodeploy login https://gd.example.org # prompts for the token
32
+ geodeploy login https://gd.example.org --password # a session too, for admin commands
33
+ echo "$PW" | geodeploy login https://gd.example.org --email me@x.org --password-stdin
34
+ """)
35
+ login.add_argument("url", help="instance URL, e.g. https://geodeploy.example.org")
36
+ # NOTE: the API token comes from the GLOBAL --token flag, so `geodeploy login <url> --token
37
+ # gdp_…` reads the same as every other command and there is only one spelling to remember.
38
+ login.add_argument("--token-stdin", action="store_true", help="read the API token from stdin")
39
+ login.add_argument("--password", action="store_true",
40
+ help="ALSO start a password session, for the admin commands that refuse "
41
+ "API tokens (prompts; the password is never stored)")
42
+ login.add_argument("--password-stdin", action="store_true",
43
+ help="read the password from stdin instead of prompting (for CI)")
44
+ login.add_argument("--email", help="account email for the password session")
45
+ login.add_argument("--name", help="profile name (default: the instance host)")
46
+ login.add_argument("--no-keyring", action="store_true",
47
+ help="store credentials in the 0600 file rather than the OS keyring")
48
+
49
+ logout = add_command(subparsers, "logout", cmd_logout,
50
+ "forget the credentials stored for an instance")
51
+ logout.add_argument("--all", action="store_true", help="every instance, not just the active one")
52
+ logout.add_argument("--session-only", action="store_true",
53
+ help="drop the password session but keep the API token")
54
+
55
+ add_command(subparsers, "whoami", cmd_whoami, "show the authenticated account and its role")
56
+
57
+ profile = group_parser(subparsers, "profile", "manage saved instances", aliases=["profiles"])
58
+ add_command(profile, "list", cmd_profile_list, "list saved profiles")
59
+ use = add_command(profile, "use", cmd_profile_use, "make a profile the active one")
60
+ use.add_argument("name")
61
+ add_command(profile, "show", cmd_profile_show,
62
+ "show the active profile and where each setting came from")
63
+ remove = add_command(profile, "remove", cmd_profile_remove, "delete a saved profile")
64
+ remove.add_argument("name")
65
+
66
+ token = group_parser(subparsers, "token", "manage API tokens (needs a password session)")
67
+ add_command(token, "list", cmd_token_list, "list your API tokens")
68
+ create = add_command(token, "create", cmd_token_create, "mint an API token (shown once)")
69
+ create.add_argument("name", help="what this token is for — it appears in Settings")
70
+ create.add_argument("--scopes", default="data:read,data:write,portal:read,portal:write,portal:publish",
71
+ help="comma-separated: data:read, data:write, portal:read, portal:write, "
72
+ "portal:publish, users:admin")
73
+ create.add_argument("--expires", type=int, default=90, choices=[30, 90, 365],
74
+ help="days until it expires (default 90)")
75
+ create.add_argument("--save", action="store_true",
76
+ help="store the new token as this instance's credential")
77
+ revoke = add_command(token, "revoke", cmd_token_revoke, "revoke an API token")
78
+ revoke.add_argument("id", type=int)
79
+
80
+
81
+ # ── login ────────────────────────────────────────────────────────────────────────────────────────
82
+
83
+ def cmd_login(ctx, args) -> int:
84
+ from ...client import Client
85
+
86
+ url = cfg.normalize_url(args.url)
87
+ out = ctx.out
88
+
89
+ token = getattr(args, "token", None)
90
+ if args.token_stdin:
91
+ token = sys.stdin.readline().strip()
92
+ if not token and not args.password and not args.password_stdin:
93
+ if not sys.stdin.isatty():
94
+ raise ConfigError("Pass --token, --token-stdin, or --password-stdin with --email.")
95
+ out.info("Create a token in {0} → Settings → API tokens. It is shown once.".format(url))
96
+ token = getpass.getpass("API token (input hidden): ").strip()
97
+
98
+ client = Client(url, token=token or None,
99
+ verify_tls=not getattr(args, "insecure", False))
100
+
101
+ jwt = None
102
+ if args.password or args.password_stdin:
103
+ email = args.email
104
+ if not email:
105
+ if not sys.stdin.isatty():
106
+ raise ConfigError("--password-stdin needs --email.")
107
+ email = input("Email: ").strip()
108
+ if args.password_stdin:
109
+ password = sys.stdin.readline().rstrip("\n")
110
+ else:
111
+ password = getpass.getpass("Password for {0} (input hidden): ".format(email))
112
+ jwt = client.login(email, password)
113
+ del password # nothing keeps it: not the config, not this process any longer than needed
114
+ client.token = token or None # restore the token; login() cleared it on the client
115
+ client.jwt = jwt
116
+
117
+ identity = client.whoami()
118
+
119
+ name = args.name or _profile_name(url)
120
+ ctx.config.set_profile(name, url, email=identity.get("email"))
121
+ ctx.config.save()
122
+ where = cfg.save_credential(url, token=token or None, jwt=jwt,
123
+ email=identity.get("email"),
124
+ use_keyring=not getattr(args, "no_keyring", False))
125
+
126
+ if ctx.out.json_mode:
127
+ ctx.out.json({"ok": True, "instance": url, "profile": name, "stored": where,
128
+ "user": identity, "session": bool(jwt)})
129
+ return EXIT_OK
130
+ out.success("Logged in to {0} as {1} ({2}).".format(url, identity.get("email"),
131
+ identity.get("role")))
132
+ out.info("Profile {0!r} is now active; credentials stored in {1}.".format(name, where))
133
+ if jwt:
134
+ out.info("A password session was stored too — that is what the admin commands use. "
135
+ "It expires in 7 days, and a password change revokes it immediately.")
136
+ elif token:
137
+ out.info("Administration commands (`geodeploy admin …`) need `--password`, because the "
138
+ "API refuses API tokens on those routes by design.")
139
+ return EXIT_OK
140
+
141
+
142
+ def _profile_name(url: str) -> str:
143
+ host = url.split("://", 1)[-1].split("/", 1)[0]
144
+ return host.split(":")[0] or "default"
145
+
146
+
147
+ def cmd_logout(ctx, args) -> int:
148
+ kind = "jwt" if args.session_only else None
149
+ if args.all:
150
+ removed = []
151
+ for name, entry in list(ctx.config.profiles.items()):
152
+ if entry.get("url") and cfg.delete_token(entry["url"], kind):
153
+ removed.append(entry["url"])
154
+ ctx.out.render({"ok": True, "forgotten": removed})
155
+ if not ctx.out.json_mode:
156
+ ctx.out.success("Forgot credentials for {0} instance(s).".format(len(removed)))
157
+ return EXIT_OK
158
+
159
+ info = ctx.resolved
160
+ if not info.url:
161
+ ctx.out.error("No instance to log out of.")
162
+ return EXIT_USAGE
163
+ removed = cfg.delete_token(info.url, kind)
164
+ if ctx.out.json_mode:
165
+ ctx.out.json({"ok": True, "instance": info.url, "forgotten": removed})
166
+ return EXIT_OK
167
+ if removed:
168
+ ctx.out.success("Forgot {0} for {1}.".format(
169
+ "the password session" if args.session_only else "the stored credentials", info.url))
170
+ else:
171
+ ctx.out.info("Nothing was stored for {0}.".format(info.url))
172
+ return EXIT_OK
173
+
174
+
175
+ # ── whoami / profiles ────────────────────────────────────────────────────────────────────────────
176
+
177
+ def cmd_whoami(ctx, args) -> int:
178
+ info = ctx.resolved
179
+ if not info.url:
180
+ ctx.out.error("Not logged in to any instance.",
181
+ hint="Run `geodeploy login <url>`, or set GEODEPLOY_URL and GEODEPLOY_TOKEN.")
182
+ return EXIT_AUTH
183
+ identity = ctx.client().whoami()
184
+ payload = dict(identity)
185
+ payload.update({"instance": info.url, "profile": info.profile,
186
+ "credential": _credential_kind(info)})
187
+ ctx.out.render(payload, ["instance", "profile", "credential", "email", "name", "role", "id"])
188
+ return EXIT_OK
189
+
190
+
191
+ def _credential_kind(info) -> str:
192
+ if info.token:
193
+ return "API token ({0})".format(info.source_token)
194
+ if info.jwt:
195
+ return "password session (stored)"
196
+ return "none"
197
+
198
+
199
+ def cmd_profile_list(ctx, args) -> int:
200
+ rows = []
201
+ for name, entry in sorted(ctx.config.profiles.items()):
202
+ stored = cfg.load_credential(entry.get("url") or "") if entry.get("url") else {}
203
+ rows.append({"active": name == ctx.config.current, "name": name,
204
+ "url": entry.get("url"), "email": entry.get("email"),
205
+ "token": bool(stored.get("token")), "session": bool(stored.get("jwt"))})
206
+ ctx.out.render(rows, ["active", "name", "url", "email", "token", "session"],
207
+ empty="No profiles yet — run `geodeploy login <url>`.")
208
+ return EXIT_OK
209
+
210
+
211
+ def cmd_profile_use(ctx, args) -> int:
212
+ if args.name not in ctx.config.profiles:
213
+ ctx.out.error("No profile named {0!r}.".format(args.name),
214
+ hint="`geodeploy profile list` shows what there is.")
215
+ return EXIT_GENERIC
216
+ ctx.config.current = args.name
217
+ ctx.config.save()
218
+ ctx.out.render({"ok": True, "current": args.name,
219
+ "url": ctx.config.profiles[args.name].get("url")})
220
+ if not ctx.out.json_mode:
221
+ ctx.out.success("Now using {0} ({1}).".format(
222
+ args.name, ctx.config.profiles[args.name].get("url")))
223
+ return EXIT_OK
224
+
225
+
226
+ def cmd_profile_show(ctx, args) -> int:
227
+ info = ctx.resolved
228
+ ctx.out.render({"profile": info.profile, "instance": info.url,
229
+ "instance_from": info.source_url, "credential": _credential_kind(info),
230
+ "credential_from": info.source_token, "email": info.email,
231
+ "config_file": cfg.config_path(),
232
+ "credentials_file": cfg.credentials_path()},
233
+ ["profile", "instance", "instance_from", "credential", "credential_from",
234
+ "email", "config_file", "credentials_file"])
235
+ return EXIT_OK
236
+
237
+
238
+ def cmd_profile_remove(ctx, args) -> int:
239
+ entry = ctx.config.profiles.get(args.name)
240
+ if not ctx.config.remove_profile(args.name):
241
+ ctx.out.error("No profile named {0!r}.".format(args.name))
242
+ return EXIT_GENERIC
243
+ ctx.config.save()
244
+ if entry and entry.get("url"):
245
+ cfg.delete_token(entry["url"])
246
+ ctx.out.render({"ok": True, "removed": args.name})
247
+ if not ctx.out.json_mode:
248
+ ctx.out.success("Removed profile {0!r} and its stored credentials.".format(args.name))
249
+ return EXIT_OK
250
+
251
+
252
+ # ── API tokens ───────────────────────────────────────────────────────────────────────────────────
253
+
254
+ def cmd_token_list(ctx, args) -> int:
255
+ rows = ctx.client(session=True).tokens()
256
+ ctx.out.render(rows, ["id", "name", "prefix", "scopes", "expires_at", "last_used_at"],
257
+ empty="No API tokens.")
258
+ return EXIT_OK
259
+
260
+
261
+ def cmd_token_create(ctx, args) -> int:
262
+ scopes = [s.strip() for s in args.scopes.split(",") if s.strip()]
263
+ created = ctx.client(session=True).create_token(args.name, scopes, args.expires)
264
+ if ctx.out.json_mode:
265
+ ctx.out.json(created)
266
+ else:
267
+ ctx.out.render(created, ["id", "name", "prefix", "scopes", "expires_at", "token"])
268
+ ctx.out.warn("Copy the token now — it is stored hashed and cannot be shown again.")
269
+ if args.save and created.get("token"):
270
+ where = cfg.save_credential(ctx.resolved.url, token=created["token"])
271
+ ctx.out.info("Saved as this instance's credential ({0}).".format(where))
272
+ return EXIT_OK
273
+
274
+
275
+ def cmd_token_revoke(ctx, args) -> int:
276
+ ctx.client(session=True).revoke_token(args.id)
277
+ ctx.out.render({"ok": True, "revoked": args.id})
278
+ if not ctx.out.json_mode:
279
+ ctx.out.success("Token {0} revoked.".format(args.id))
280
+ return EXIT_OK