exist-shell 0.1.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,300 @@
1
+ """User management commands (ls, add, rm, info, passwd)."""
2
+
3
+ import sys
4
+
5
+ import typer
6
+
7
+ from exist_shell.client import ExistClient
8
+ from exist_shell.completions import server_at_completer, server_nick_completer, user_arg_completer
9
+ from exist_shell.config import Config
10
+ from exist_shell.exceptions import ExistAuthError, ExistConnectionError, ExistQueryError
11
+ from exist_shell.utils import parse_user_at_server
12
+
13
+ app = typer.Typer(help="Manage eXist-db users.", no_args_is_help=True)
14
+
15
+
16
+ def _stdin_is_tty() -> bool:
17
+ """Return True when stdin is connected to a real terminal."""
18
+ return sys.stdin.isatty()
19
+
20
+
21
+ def _resolve_server(
22
+ config: Config,
23
+ inline_server: str | None,
24
+ flag_server: str | None,
25
+ ) -> str:
26
+ """Resolve the effective server nick from an inline @server and --server flag.
27
+
28
+ Args:
29
+ config: The loaded configuration.
30
+ inline_server: Server nick extracted from a ``user@server`` or ``@server``
31
+ argument; ``None`` if no ``@`` suffix was present.
32
+ flag_server: Server nick provided via the ``--server`` option; ``None``
33
+ if the flag was omitted.
34
+
35
+ Returns:
36
+ The resolved server nick, guaranteed to exist in ``config.servers``.
37
+
38
+ Raises:
39
+ typer.Exit: If both sources conflict, no server can be determined, or
40
+ the resolved nick does not exist in the configuration.
41
+ """
42
+ if not config.servers:
43
+ typer.echo("Error: no servers configured. Use 'exsh server add' first.", err=True)
44
+ raise typer.Exit(1)
45
+ if inline_server and flag_server:
46
+ typer.echo(
47
+ "Error: conflicting server specifications: use @server or --server, not both.",
48
+ err=True,
49
+ )
50
+ raise typer.Exit(1)
51
+ server = inline_server or flag_server
52
+ if server is None:
53
+ if len(config.servers) != 1:
54
+ typer.echo(
55
+ "Error: --server is required when multiple servers are configured.",
56
+ err=True,
57
+ )
58
+ raise typer.Exit(1)
59
+ server = next(iter(config.servers))
60
+ if server not in config.servers:
61
+ typer.echo(f"Error: server '{server}' not found.", err=True)
62
+ raise typer.Exit(1)
63
+ return server
64
+
65
+
66
+ @app.command("ls")
67
+ def user_ls(
68
+ at_server: str | None = typer.Argument(None, metavar="@SERVER", help="Server in @nick form (e.g. @prod).", autocompletion=server_at_completer),
69
+ server: str | None = typer.Option(None, "--server", help="Server nick to query.", autocompletion=server_nick_completer),
70
+ ) -> None:
71
+ """List all user accounts and their group memberships.
72
+
73
+ The server may be specified as a bare ``@nick`` positional argument
74
+ (e.g. ``user ls @prod``) or via ``--server``. When omitted and only one
75
+ server is configured it is selected automatically.
76
+
77
+ Args:
78
+ at_server: Optional server nick in ``@nick`` form.
79
+ server: Server nick to query. Auto-selected when only one server is
80
+ configured; required when multiple servers are configured.
81
+ """
82
+ inline_server: str | None = None
83
+ if at_server is not None:
84
+ if not at_server.startswith("@"):
85
+ typer.echo("Error: positional server argument must be in @nick form (e.g. @prod).", err=True)
86
+ raise typer.Exit(1)
87
+ inline_server = at_server[1:]
88
+ if not inline_server:
89
+ typer.echo("Error: server nick cannot be empty.", err=True)
90
+ raise typer.Exit(1)
91
+
92
+ config = Config.load()
93
+ resolved = _resolve_server(config, inline_server, server)
94
+
95
+ try:
96
+ with ExistClient(config.servers[resolved]) as client:
97
+ users = client.list_users()
98
+ except ExistAuthError:
99
+ typer.echo(f"Error: authentication failed for server '{resolved}'.", err=True)
100
+ raise typer.Exit(1)
101
+ except ExistConnectionError as e:
102
+ typer.echo(f"Error: {e}", err=True)
103
+ raise typer.Exit(1)
104
+
105
+ for u in users:
106
+ typer.echo(f"{u.username}\t{', '.join(u.groups)}")
107
+
108
+
109
+ @app.command("add")
110
+ def user_add(
111
+ username: str = typer.Argument(help="Account name to create, optionally as user@server.", autocompletion=user_arg_completer),
112
+ group: str = typer.Option("guest", "--group", help="Comma-separated group names. The first is the primary group."),
113
+ password: str | None = typer.Option(None, "--password", help="Password (prompted if omitted)."),
114
+ server: str | None = typer.Option(None, "--server", help="Server nick to target.", autocompletion=server_nick_completer),
115
+ ) -> None:
116
+ """Create a new user account on the server.
117
+
118
+ The username may include an inline server nick using ``user@server``
119
+ syntax (e.g. ``alice@prod``). Prompts for a password when ``--password``
120
+ is not supplied so the credential is never written to the shell history.
121
+
122
+ Args:
123
+ username: The new account name, optionally suffixed with ``@server_nick``.
124
+ group: Comma-separated group names. The first becomes the primary group.
125
+ Defaults to ``guest``.
126
+ password: Plaintext password. Prompted interactively when omitted.
127
+ server: Server nick to target. Auto-selected when only one server is
128
+ configured; required when multiple servers are configured.
129
+ """
130
+ bare_username, inline_server = parse_user_at_server(username)
131
+ if not bare_username:
132
+ typer.echo("Error: username cannot be empty.", err=True)
133
+ raise typer.Exit(1)
134
+
135
+ config = Config.load()
136
+ resolved = _resolve_server(config, inline_server, server)
137
+
138
+ groups = [g.strip() for g in group.split(",") if g.strip()]
139
+ if not groups:
140
+ typer.echo("Error: at least one group is required.", err=True)
141
+ raise typer.Exit(1)
142
+
143
+ if password is None:
144
+ password = typer.prompt(f"Password for '{bare_username}'", hide_input=True, confirmation_prompt=True)
145
+
146
+ try:
147
+ with ExistClient(config.servers[resolved]) as client:
148
+ client.create_user(bare_username, password, groups)
149
+ except ExistAuthError:
150
+ typer.echo(f"Error: authentication failed for server '{resolved}'.", err=True)
151
+ raise typer.Exit(1)
152
+ except ExistConnectionError as e:
153
+ typer.echo(f"Error: {e}", err=True)
154
+ raise typer.Exit(1)
155
+ except ExistQueryError as e:
156
+ typer.echo(f"Error: {e}", err=True)
157
+ raise typer.Exit(1)
158
+
159
+ typer.echo(f"User '{bare_username}' created.")
160
+
161
+
162
+ @app.command("rm")
163
+ def user_rm(
164
+ username: str = typer.Argument(help="Account name to remove, optionally as user@server.", autocompletion=user_arg_completer),
165
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt."),
166
+ server: str | None = typer.Option(None, "--server", help="Server nick to target.", autocompletion=server_nick_completer),
167
+ ) -> None:
168
+ """Remove a user account from the server.
169
+
170
+ The username may include an inline server nick using ``user@server``
171
+ syntax (e.g. ``alice@prod``). Prompts for confirmation unless ``--yes``
172
+ is supplied.
173
+
174
+ Args:
175
+ username: The account name to remove, optionally suffixed with ``@server_nick``.
176
+ yes: When True, skip the confirmation prompt.
177
+ server: Server nick to target. Auto-selected when only one server is
178
+ configured; required when multiple servers are configured.
179
+ """
180
+ bare_username, inline_server = parse_user_at_server(username)
181
+ if not bare_username:
182
+ typer.echo("Error: username cannot be empty.", err=True)
183
+ raise typer.Exit(1)
184
+
185
+ config = Config.load()
186
+ resolved = _resolve_server(config, inline_server, server)
187
+
188
+ if not yes:
189
+ typer.confirm(f"Remove user '{bare_username}'?", abort=True)
190
+
191
+ try:
192
+ with ExistClient(config.servers[resolved]) as client:
193
+ client.delete_user(bare_username)
194
+ except ExistAuthError:
195
+ typer.echo(f"Error: authentication failed for server '{resolved}'.", err=True)
196
+ raise typer.Exit(1)
197
+ except ExistConnectionError as e:
198
+ typer.echo(f"Error: {e}", err=True)
199
+ raise typer.Exit(1)
200
+ except ExistQueryError as e:
201
+ typer.echo(f"Error: {e}", err=True)
202
+ raise typer.Exit(1)
203
+
204
+ typer.echo(f"User '{bare_username}' removed.")
205
+
206
+
207
+ @app.command("info")
208
+ def user_info(
209
+ username: str = typer.Argument(help="Account name to inspect, optionally as user@server.", autocompletion=user_arg_completer),
210
+ server: str | None = typer.Option(None, "--server", help="Server nick to query.", autocompletion=server_nick_completer),
211
+ ) -> None:
212
+ """Show detailed information about a user account.
213
+
214
+ The username may include an inline server nick using ``user@server``
215
+ syntax (e.g. ``alice@prod``).
216
+
217
+ Args:
218
+ username: The account name to inspect, optionally suffixed with ``@server_nick``.
219
+ server: Server nick to query. Auto-selected when only one server is
220
+ configured; required when multiple servers are configured.
221
+ """
222
+ bare_username, inline_server = parse_user_at_server(username)
223
+ if not bare_username:
224
+ typer.echo("Error: username cannot be empty.", err=True)
225
+ raise typer.Exit(1)
226
+
227
+ config = Config.load()
228
+ resolved = _resolve_server(config, inline_server, server)
229
+
230
+ try:
231
+ with ExistClient(config.servers[resolved]) as client:
232
+ info = client.get_user(bare_username)
233
+ except ExistAuthError:
234
+ typer.echo(f"Error: authentication failed for server '{resolved}'.", err=True)
235
+ raise typer.Exit(1)
236
+ except ExistConnectionError as e:
237
+ typer.echo(f"Error: {e}", err=True)
238
+ raise typer.Exit(1)
239
+ except ExistQueryError as e:
240
+ typer.echo(f"Error: {e}", err=True)
241
+ raise typer.Exit(1)
242
+
243
+ typer.echo(f"Username: {info.username}")
244
+ if info.full_name:
245
+ typer.echo(f"Full name: {info.full_name}")
246
+ typer.echo(f"Groups: {', '.join(info.groups)}")
247
+ typer.echo(f"Enabled: {info.enabled}")
248
+
249
+
250
+ @app.command("passwd")
251
+ def user_passwd(
252
+ username: str = typer.Argument(help="Account name, optionally as user@server.", autocompletion=user_arg_completer),
253
+ from_stdin: bool = typer.Option(False, "--stdin", help="Read new password from stdin (for scripting)."),
254
+ server: str | None = typer.Option(None, "--server", help="Server nick to target.", autocompletion=server_nick_completer),
255
+ ) -> None:
256
+ """Change a user's password on the server.
257
+
258
+ The username may include an inline server nick using ``user@server``
259
+ syntax (e.g. ``alice@prod``). Prompts for the new password interactively
260
+ (with confirmation) when running on a TTY. When stdin is not a TTY (pipe
261
+ or redirect), or when ``--stdin`` is supplied explicitly, the password is
262
+ read as a single line from standard input without a confirmation prompt —
263
+ suitable for piped automation. The password is never accepted on the
264
+ command line to avoid shell history exposure.
265
+
266
+ Args:
267
+ username: The account name, optionally suffixed with ``@server_nick``.
268
+ from_stdin: When True, read the new password from stdin instead of
269
+ prompting interactively. Also activated automatically when stdin
270
+ is not connected to a TTY.
271
+ server: Server nick to target. Auto-selected when only one server is
272
+ configured; required when multiple servers are configured.
273
+ """
274
+ bare_username, inline_server = parse_user_at_server(username)
275
+ if not bare_username:
276
+ typer.echo("Error: username cannot be empty.", err=True)
277
+ raise typer.Exit(1)
278
+
279
+ config = Config.load()
280
+ resolved = _resolve_server(config, inline_server, server)
281
+
282
+ if from_stdin or not _stdin_is_tty():
283
+ password = sys.stdin.readline().rstrip("\n")
284
+ else:
285
+ password = typer.prompt(f"New password for '{bare_username}'", hide_input=True, confirmation_prompt=True)
286
+
287
+ try:
288
+ with ExistClient(config.servers[resolved]) as client:
289
+ client.change_password(bare_username, password)
290
+ except ExistAuthError:
291
+ typer.echo(f"Error: authentication failed for server '{resolved}'.", err=True)
292
+ raise typer.Exit(1)
293
+ except ExistConnectionError as e:
294
+ typer.echo(f"Error: {e}", err=True)
295
+ raise typer.Exit(1)
296
+ except ExistQueryError as e:
297
+ typer.echo(f"Error: {e}", err=True)
298
+ raise typer.Exit(1)
299
+
300
+ typer.echo(f"Password for '{bare_username}' updated.")
@@ -0,0 +1,221 @@
1
+ """Shell completion helpers for eXist collection and document paths."""
2
+
3
+ from typing import Literal
4
+
5
+ from exist_shell.cache import get_cached, get_cached_groups, get_cached_users, set_cached, set_cached_groups, set_cached_users
6
+ from exist_shell.client import ExistClient
7
+ from exist_shell.config import Config
8
+ from exist_shell.models import CollectionEntry
9
+
10
+ Kind = Literal["any", "collection", "resource"]
11
+
12
+
13
+ def collection_target_completer(kind: Kind = "any", *, allow_local: bool = False):
14
+ """Return a completion function for ``<nick>:<path>`` arguments filtered by item kind.
15
+
16
+ Args:
17
+ kind: Which item types to include — ``"collection"`` for subcollections
18
+ only, ``"resource"`` for documents only, ``"any"`` for both.
19
+ allow_local: When True and the incomplete string contains no ``:``,
20
+ return an empty list so the shell falls back to its default
21
+ filesystem completion. When False (default), offer collection
22
+ nick completions instead.
23
+
24
+ Returns:
25
+ A completion function compatible with Typer's ``autocompletion`` parameter.
26
+ """
27
+ def _complete(incomplete: str) -> list[str]:
28
+ try:
29
+ config = Config.load()
30
+ except Exception:
31
+ return []
32
+
33
+ if ":" not in incomplete:
34
+ if allow_local:
35
+ return []
36
+ return [f"{nick}:" for nick in config.collections if nick.startswith(incomplete)]
37
+
38
+ nick, partial_path = incomplete.split(":", 1)
39
+ if nick not in config.collections:
40
+ return []
41
+
42
+ if not partial_path.startswith("/"):
43
+ partial_path = "/" + partial_path
44
+
45
+ last_slash = partial_path.rfind("/")
46
+ dir_path = partial_path[: last_slash + 1]
47
+ prefix = partial_path[last_slash + 1:]
48
+
49
+ collection = config.collections[nick]
50
+ server = config.servers[collection.server_nick]
51
+ full_dir = f"/db/{collection.name}{dir_path}"
52
+
53
+ try:
54
+ items = get_cached(nick, dir_path)
55
+ if items is None:
56
+ with ExistClient(server) as client:
57
+ items = client.list_collection(full_dir)
58
+ set_cached(nick, dir_path, items)
59
+ except Exception:
60
+ return []
61
+
62
+ results = []
63
+ for item in items:
64
+ is_col = isinstance(item, CollectionEntry)
65
+ if kind == "collection" and not is_col:
66
+ continue
67
+ if kind == "resource" and is_col:
68
+ continue
69
+ item_name = item.name + ("/" if is_col else "")
70
+ if item_name.startswith(prefix):
71
+ results.append(f"{nick}:{dir_path}{item_name}")
72
+ return results
73
+
74
+ return _complete
75
+
76
+
77
+ def chown_spec_completer(incomplete: str) -> list[str]:
78
+ """Complete an ``owner_spec`` argument for the ``chown`` command.
79
+
80
+ Accepts an optional ``server@`` prefix to select which server to query for
81
+ user and group names. All six forms are supported:
82
+
83
+ - ``user`` / ``:group`` / ``user:group`` — uses the first reachable server.
84
+ - ``server@user`` / ``server@:group`` / ``server@user:group`` — uses the
85
+ named server; the ``server@`` prefix is stripped by the command before
86
+ the ownership change is applied.
87
+
88
+ When no ``@`` is present and multiple servers are configured, server-nick
89
+ completions (``server@``) are also offered so the user can pin a server.
90
+
91
+ Args:
92
+ incomplete: The partially typed owner spec.
93
+
94
+ Returns:
95
+ List of completion candidates.
96
+ """
97
+ try:
98
+ config = Config.load()
99
+ except Exception:
100
+ return []
101
+
102
+ # --- resolve server and the "rest" to complete ----------------------------
103
+ server = None
104
+ resolved_nick = ""
105
+ prefix = "" # "server@" to prepend to every candidate
106
+ rest = incomplete
107
+
108
+ if "@" in incomplete:
109
+ server_nick, _, rest = incomplete.partition("@")
110
+ if server_nick in config.servers:
111
+ server = config.servers[server_nick]
112
+ resolved_nick = server_nick
113
+ prefix = f"{server_nick}@"
114
+ else:
115
+ # Still typing the server nick: offer matching "server@" completions.
116
+ return [f"{s}@" for s in config.servers if s.startswith(server_nick)]
117
+ else:
118
+ # Pick the first configured server as a default.
119
+ for nick, s in config.servers.items():
120
+ server = s
121
+ resolved_nick = nick
122
+ break
123
+
124
+ if server is None:
125
+ return []
126
+
127
+ # --- fetch users / groups and build candidates ----------------------------
128
+ try:
129
+ if ":" in rest:
130
+ user_part, _, partial_group = rest.partition(":")
131
+ groups = get_cached_groups(resolved_nick)
132
+ if groups is None:
133
+ with ExistClient(server) as client:
134
+ groups = client.list_groups()
135
+ set_cached_groups(resolved_nick, groups)
136
+ return [
137
+ f"{prefix}{user_part}:{g.name}"
138
+ for g in groups
139
+ if g.name.startswith(partial_group)
140
+ ]
141
+ else:
142
+ users = get_cached_users(resolved_nick)
143
+ if users is None:
144
+ with ExistClient(server) as client:
145
+ users = client.list_users()
146
+ set_cached_users(resolved_nick, users)
147
+ results = [
148
+ f"{prefix}{u.username}"
149
+ for u in users
150
+ if u.username.startswith(rest)
151
+ ]
152
+ # When no server pin typed yet and multiple servers exist,
153
+ # also offer "server@" so the user can pick a specific server.
154
+ if not prefix and len(config.servers) > 1:
155
+ results += [f"{s}@" for s in config.servers if s.startswith(rest)]
156
+ return results
157
+ except Exception:
158
+ return []
159
+
160
+
161
+ def server_nick_completer(incomplete: str) -> list[str]:
162
+ """Complete a ``--server`` option value from configured server nicks.
163
+
164
+ Args:
165
+ incomplete: The partially typed server nick.
166
+
167
+ Returns:
168
+ List of server nicks that start with ``incomplete``.
169
+ """
170
+ try:
171
+ config = Config.load()
172
+ except Exception:
173
+ return []
174
+ return [nick for nick in config.servers if nick.startswith(incomplete)]
175
+
176
+
177
+ def user_arg_completer(incomplete: str) -> list[str]:
178
+ """Complete ``user@server`` arguments by offering server-profile suffixes.
179
+
180
+ Handles three forms:
181
+
182
+ - No ``@`` present: returns nothing (usernames are open-ended).
183
+ - ``alice@``: returns ``alice@<nick>`` for each server whose nick starts
184
+ with the text after ``@``.
185
+ - ``alice@prod``: already resolved, returns nothing.
186
+
187
+ Args:
188
+ incomplete: The partially typed argument value.
189
+
190
+ Returns:
191
+ List of completion candidates.
192
+ """
193
+ try:
194
+ config = Config.load()
195
+ except Exception:
196
+ return []
197
+ if "@" in incomplete:
198
+ user_part, _, server_part = incomplete.rpartition("@")
199
+ return [f"{user_part}@{nick}" for nick in config.servers if nick.startswith(server_part)]
200
+ return []
201
+
202
+
203
+ def server_at_completer(incomplete: str) -> list[str]:
204
+ """Complete a bare ``@server`` argument (used by commands like ``user ls``).
205
+
206
+ Args:
207
+ incomplete: The partially typed argument value (e.g. ``@`` or ``@pr``).
208
+
209
+ Returns:
210
+ List of ``@nick`` candidates whose nick starts with the text after ``@``.
211
+ """
212
+ try:
213
+ config = Config.load()
214
+ except Exception:
215
+ return []
216
+ if incomplete.startswith("@"):
217
+ prefix = incomplete[1:]
218
+ return [f"@{nick}" for nick in config.servers if nick.startswith(prefix)]
219
+ if not incomplete:
220
+ return [f"@{nick}" for nick in config.servers]
221
+ return []