netbox-super-cli 1.0.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.
- netbox_super_cli-1.0.0.dist-info/METADATA +182 -0
- netbox_super_cli-1.0.0.dist-info/RECORD +71 -0
- netbox_super_cli-1.0.0.dist-info/WHEEL +4 -0
- netbox_super_cli-1.0.0.dist-info/entry_points.txt +3 -0
- netbox_super_cli-1.0.0.dist-info/licenses/LICENSE +201 -0
- nsc/__init__.py +5 -0
- nsc/__main__.py +6 -0
- nsc/_version.py +3 -0
- nsc/aliases/__init__.py +24 -0
- nsc/aliases/resolver.py +112 -0
- nsc/auth/__init__.py +5 -0
- nsc/auth/verify.py +143 -0
- nsc/builder/__init__.py +5 -0
- nsc/builder/build.py +514 -0
- nsc/cache/__init__.py +5 -0
- nsc/cache/store.py +295 -0
- nsc/cli/__init__.py +1 -0
- nsc/cli/aliases_commands.py +264 -0
- nsc/cli/app.py +291 -0
- nsc/cli/cache_commands.py +159 -0
- nsc/cli/commands_dump.py +57 -0
- nsc/cli/config_commands.py +156 -0
- nsc/cli/globals.py +65 -0
- nsc/cli/handlers.py +660 -0
- nsc/cli/init_commands.py +95 -0
- nsc/cli/login_commands.py +265 -0
- nsc/cli/profiles_commands.py +256 -0
- nsc/cli/registration.py +465 -0
- nsc/cli/runtime.py +290 -0
- nsc/cli/skill_commands.py +186 -0
- nsc/cli/writes/__init__.py +10 -0
- nsc/cli/writes/apply.py +177 -0
- nsc/cli/writes/bulk.py +231 -0
- nsc/cli/writes/coercion.py +9 -0
- nsc/cli/writes/confirmation.py +96 -0
- nsc/cli/writes/input.py +358 -0
- nsc/cli/writes/preflight.py +182 -0
- nsc/config/__init__.py +23 -0
- nsc/config/loader.py +69 -0
- nsc/config/models.py +54 -0
- nsc/config/settings.py +36 -0
- nsc/config/writer.py +207 -0
- nsc/http/__init__.py +6 -0
- nsc/http/audit.py +183 -0
- nsc/http/client.py +365 -0
- nsc/http/errors.py +35 -0
- nsc/http/retry.py +90 -0
- nsc/model/__init__.py +23 -0
- nsc/model/command_model.py +125 -0
- nsc/output/__init__.py +1 -0
- nsc/output/csv_.py +34 -0
- nsc/output/errors.py +346 -0
- nsc/output/explain.py +194 -0
- nsc/output/flatten.py +25 -0
- nsc/output/headers.py +9 -0
- nsc/output/json_.py +21 -0
- nsc/output/jsonl.py +21 -0
- nsc/output/render.py +47 -0
- nsc/output/table.py +50 -0
- nsc/output/yaml_.py +28 -0
- nsc/schema/__init__.py +1 -0
- nsc/schema/hashing.py +24 -0
- nsc/schema/loader.py +66 -0
- nsc/schema/models.py +109 -0
- nsc/schema/source.py +120 -0
- nsc/schemas/__init__.py +1 -0
- nsc/schemas/bundled/__init__.py +1 -0
- nsc/schemas/bundled/manifest.yaml +5 -0
- nsc/schemas/bundled/netbox-4.6.0-beta2.json.gz +0 -0
- nsc/skill/__init__.py +35 -0
- skills/netbox-super-cli/SKILL.md +127 -0
nsc/cli/init_commands.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""`nsc init` — first-run wizard.
|
|
2
|
+
|
|
3
|
+
Prompts for a profile name, URL, and token storage mode, then writes a minimal
|
|
4
|
+
`~/.nsc/config.yaml`. Refuses to clobber an existing non-empty config (spec §4.2);
|
|
5
|
+
the user should `nsc login --new --profile <name>` to add a profile to an
|
|
6
|
+
existing config instead.
|
|
7
|
+
|
|
8
|
+
`init` is offline-safe: it does not call `verify()` — that is `login`'s job.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
from ruamel.yaml.comments import CommentedMap, TaggedScalar
|
|
17
|
+
|
|
18
|
+
from nsc.config.settings import default_paths
|
|
19
|
+
from nsc.config.writer import (
|
|
20
|
+
ConfigWriteError,
|
|
21
|
+
acquire_lock,
|
|
22
|
+
atomic_write,
|
|
23
|
+
dump_round_trip,
|
|
24
|
+
load_round_trip,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _config_path() -> Path:
|
|
29
|
+
return default_paths().config_file
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _existing_is_empty(path: Path) -> bool:
|
|
33
|
+
"""True only when the file parses as an empty mapping; malformed → False.
|
|
34
|
+
|
|
35
|
+
A malformed pre-existing config is more worth refusing to clobber, not less —
|
|
36
|
+
so any parse failure is treated as 'present and non-empty.'
|
|
37
|
+
"""
|
|
38
|
+
try:
|
|
39
|
+
existing = load_round_trip(path)
|
|
40
|
+
except ConfigWriteError:
|
|
41
|
+
return False
|
|
42
|
+
return len(existing) == 0
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _build_doc(
|
|
46
|
+
profile_name: str,
|
|
47
|
+
url: str,
|
|
48
|
+
token_value: object,
|
|
49
|
+
) -> CommentedMap:
|
|
50
|
+
"""Produce the minimal config doc for a fresh init run."""
|
|
51
|
+
profiles = CommentedMap()
|
|
52
|
+
profile = CommentedMap()
|
|
53
|
+
profile["url"] = url
|
|
54
|
+
profile["token"] = token_value
|
|
55
|
+
profiles[profile_name] = profile
|
|
56
|
+
|
|
57
|
+
doc = CommentedMap()
|
|
58
|
+
doc["default_profile"] = profile_name
|
|
59
|
+
doc["profiles"] = profiles
|
|
60
|
+
return doc
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _env_tagged(var_name: str) -> object:
|
|
64
|
+
"""Build a `!env <VARNAME>` scalar that ruamel will emit with its tag intact."""
|
|
65
|
+
return TaggedScalar(value=var_name, style=None, tag="!env")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def register(app: typer.Typer) -> None:
|
|
69
|
+
@app.command("init", help="First-run wizard — create ~/.nsc/config.yaml.")
|
|
70
|
+
def init_cmd() -> None:
|
|
71
|
+
path = _config_path()
|
|
72
|
+
if path.exists() and not _existing_is_empty(path):
|
|
73
|
+
typer.echo(
|
|
74
|
+
f"error: config already exists at {path}; use `nsc login --new` to add a profile.",
|
|
75
|
+
err=True,
|
|
76
|
+
)
|
|
77
|
+
raise typer.Exit(code=12)
|
|
78
|
+
|
|
79
|
+
profile_name = typer.prompt("Profile name", default="default")
|
|
80
|
+
url = typer.prompt("NetBox URL (e.g. https://netbox.example.com/)")
|
|
81
|
+
storage = typer.prompt("Token storage [plaintext|env]", default="plaintext").strip().lower()
|
|
82
|
+
token_value: object
|
|
83
|
+
if storage == "env":
|
|
84
|
+
var_name = typer.prompt("Environment variable name (e.g. NSC_PROD_TOKEN)")
|
|
85
|
+
token_value = _env_tagged(var_name.strip())
|
|
86
|
+
else:
|
|
87
|
+
token_value = typer.prompt("Token", hide_input=True)
|
|
88
|
+
|
|
89
|
+
doc = _build_doc(profile_name.strip(), url.strip(), token_value)
|
|
90
|
+
|
|
91
|
+
with acquire_lock(path):
|
|
92
|
+
atomic_write(path, dump_round_trip(doc))
|
|
93
|
+
|
|
94
|
+
typer.echo(f"wrote {path}")
|
|
95
|
+
typer.echo(f"next: nsc login --profile {profile_name.strip()}")
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""`nsc login` — verify / create / rotate a profile's token.
|
|
2
|
+
|
|
3
|
+
Three modes (mutually exclusive):
|
|
4
|
+
|
|
5
|
+
* bare or `--profile <name>` — verify an existing profile against the live NetBox.
|
|
6
|
+
* `--new --profile <name>` — create a new profile entry, then verify.
|
|
7
|
+
* `--rotate --profile <name>` — prompt for a new token, verify, replace in storage.
|
|
8
|
+
|
|
9
|
+
All three end with `verify()`; only success persists changes (for `--rotate`)
|
|
10
|
+
or returns 0 (for bare). Cache is never touched.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Annotated
|
|
17
|
+
|
|
18
|
+
import typer
|
|
19
|
+
from ruamel.yaml.comments import CommentedMap, TaggedScalar
|
|
20
|
+
|
|
21
|
+
from nsc.auth.verify import VerifyError, verify
|
|
22
|
+
from nsc.cli.runtime import emit_envelope
|
|
23
|
+
from nsc.config.loader import ConfigParseError, load_config
|
|
24
|
+
from nsc.config.models import OutputFormat, Profile
|
|
25
|
+
from nsc.config.settings import default_paths
|
|
26
|
+
from nsc.config.writer import (
|
|
27
|
+
acquire_lock,
|
|
28
|
+
atomic_write,
|
|
29
|
+
dump_round_trip,
|
|
30
|
+
load_round_trip,
|
|
31
|
+
)
|
|
32
|
+
from nsc.output.errors import ErrorEnvelope, ErrorType
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _config_path() -> Path:
|
|
36
|
+
return default_paths().config_file
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _env_tagged(var_name: str) -> object:
|
|
40
|
+
"""Build a `!env <VARNAME>` scalar that ruamel will emit with its tag intact."""
|
|
41
|
+
return TaggedScalar(value=var_name, style=None, tag="!env")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _emit_auth_envelope(
|
|
45
|
+
message: str,
|
|
46
|
+
*,
|
|
47
|
+
status_code: int | None,
|
|
48
|
+
user_check_status: int | None,
|
|
49
|
+
) -> int:
|
|
50
|
+
details: dict[str, object] = {"reason": "rejected"}
|
|
51
|
+
if user_check_status is not None:
|
|
52
|
+
details["user_check_status"] = user_check_status
|
|
53
|
+
env = ErrorEnvelope(
|
|
54
|
+
error=message,
|
|
55
|
+
type=ErrorType.AUTH,
|
|
56
|
+
status_code=status_code,
|
|
57
|
+
details=details,
|
|
58
|
+
)
|
|
59
|
+
return emit_envelope(env, output_format=OutputFormat.TABLE)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _emit_config_envelope(message: str) -> int:
|
|
63
|
+
env = ErrorEnvelope(error=message, type=ErrorType.CONFIG)
|
|
64
|
+
return emit_envelope(env, output_format=OutputFormat.TABLE)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _resolved_profile(name: str) -> Profile:
|
|
68
|
+
"""Return the validated `Profile` for `name`, raising on missing/invalid config."""
|
|
69
|
+
try:
|
|
70
|
+
config = load_config(_config_path())
|
|
71
|
+
except ConfigParseError as exc:
|
|
72
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
73
|
+
if name not in config.profiles:
|
|
74
|
+
raise typer.BadParameter(f"profile {name!r} not in config")
|
|
75
|
+
return config.profiles[name]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _ensure_profile_exists_in_doc(doc: CommentedMap, name: str) -> CommentedMap:
|
|
79
|
+
profiles = doc.get("profiles")
|
|
80
|
+
if not isinstance(profiles, CommentedMap):
|
|
81
|
+
profiles = CommentedMap()
|
|
82
|
+
doc["profiles"] = profiles
|
|
83
|
+
if name not in profiles:
|
|
84
|
+
profiles[name] = CommentedMap()
|
|
85
|
+
entry = profiles[name]
|
|
86
|
+
assert isinstance(entry, CommentedMap)
|
|
87
|
+
return entry
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _write_profile_entry(*, profile: str, url: str, token_value: object, set_default: bool) -> None:
|
|
91
|
+
path = _config_path()
|
|
92
|
+
with acquire_lock(path):
|
|
93
|
+
doc = load_round_trip(path)
|
|
94
|
+
if set_default and "default_profile" not in doc:
|
|
95
|
+
doc["default_profile"] = profile
|
|
96
|
+
entry = _ensure_profile_exists_in_doc(doc, profile)
|
|
97
|
+
entry["url"] = url
|
|
98
|
+
entry["token"] = token_value
|
|
99
|
+
atomic_write(path, dump_round_trip(doc))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _replace_token(profile: str, token_value: object) -> None:
|
|
103
|
+
path = _config_path()
|
|
104
|
+
with acquire_lock(path):
|
|
105
|
+
doc = load_round_trip(path)
|
|
106
|
+
profiles = doc.get("profiles")
|
|
107
|
+
if not isinstance(profiles, CommentedMap) or profile not in profiles:
|
|
108
|
+
raise typer.BadParameter(f"profile {profile!r} not in config")
|
|
109
|
+
entry = profiles[profile]
|
|
110
|
+
if not isinstance(entry, CommentedMap):
|
|
111
|
+
raise typer.BadParameter(f"profile {profile!r} is not a mapping")
|
|
112
|
+
entry["token"] = token_value
|
|
113
|
+
atomic_write(path, dump_round_trip(doc))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _print_success(username: str, version: str) -> None:
|
|
117
|
+
typer.echo(f"✓ authenticated as {username}, NetBox {version}")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def register(app: typer.Typer) -> None:
|
|
121
|
+
@app.command("login", help="Verify / create / rotate a profile's token.")
|
|
122
|
+
def login_cmd(
|
|
123
|
+
profile: Annotated[str | None, typer.Option("--profile")] = None,
|
|
124
|
+
new: Annotated[bool, typer.Option("--new", help="Create a new profile.")] = False,
|
|
125
|
+
rotate: Annotated[
|
|
126
|
+
bool, typer.Option("--rotate", help="Replace an existing profile's token.")
|
|
127
|
+
] = False,
|
|
128
|
+
url: Annotated[str | None, typer.Option("--url")] = None,
|
|
129
|
+
store: Annotated[
|
|
130
|
+
str, typer.Option("--store", help="Token storage: plaintext|env.")
|
|
131
|
+
] = "plaintext",
|
|
132
|
+
env_var: Annotated[
|
|
133
|
+
str | None,
|
|
134
|
+
typer.Option(
|
|
135
|
+
"--env-var",
|
|
136
|
+
help="Environment variable name (required when --store=env).",
|
|
137
|
+
),
|
|
138
|
+
] = None,
|
|
139
|
+
) -> None:
|
|
140
|
+
if new and rotate:
|
|
141
|
+
raise typer.BadParameter("--new and --rotate are mutually exclusive")
|
|
142
|
+
|
|
143
|
+
if new:
|
|
144
|
+
_do_login_new(profile, url, store, env_var)
|
|
145
|
+
return
|
|
146
|
+
if rotate:
|
|
147
|
+
_do_login_rotate(profile, store, env_var)
|
|
148
|
+
return
|
|
149
|
+
_do_login_verify(profile)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _do_login_verify(profile_name: str | None) -> None:
|
|
153
|
+
try:
|
|
154
|
+
config = load_config(_config_path())
|
|
155
|
+
except ConfigParseError as exc:
|
|
156
|
+
code = _emit_config_envelope(str(exc))
|
|
157
|
+
raise typer.Exit(code=code) from exc
|
|
158
|
+
name = profile_name or config.default_profile
|
|
159
|
+
if name is None:
|
|
160
|
+
code = _emit_config_envelope("no profile selected and no default_profile set in config")
|
|
161
|
+
raise typer.Exit(code=code)
|
|
162
|
+
if name not in config.profiles:
|
|
163
|
+
code = _emit_config_envelope(f"profile {name!r} not in config")
|
|
164
|
+
raise typer.Exit(code=code)
|
|
165
|
+
profile = config.profiles[name]
|
|
166
|
+
try:
|
|
167
|
+
result = verify(profile)
|
|
168
|
+
except VerifyError as exc:
|
|
169
|
+
code = _emit_auth_envelope(
|
|
170
|
+
str(exc),
|
|
171
|
+
status_code=exc.status_code,
|
|
172
|
+
user_check_status=exc.user_check_status,
|
|
173
|
+
)
|
|
174
|
+
raise typer.Exit(code=code) from exc
|
|
175
|
+
_print_success(result.username, result.netbox_version)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _do_login_new(
|
|
179
|
+
profile_name: str | None,
|
|
180
|
+
url: str | None,
|
|
181
|
+
store: str,
|
|
182
|
+
env_var: str | None,
|
|
183
|
+
) -> None:
|
|
184
|
+
if profile_name is None:
|
|
185
|
+
raise typer.BadParameter("--new requires --profile <name>")
|
|
186
|
+
if url is None:
|
|
187
|
+
raise typer.BadParameter("--new requires --url <url>")
|
|
188
|
+
try:
|
|
189
|
+
existing_config = load_config(_config_path())
|
|
190
|
+
except ConfigParseError:
|
|
191
|
+
existing_config = None
|
|
192
|
+
if existing_config and profile_name in existing_config.profiles:
|
|
193
|
+
typer.echo(
|
|
194
|
+
f"error: profile {profile_name!r} already exists; "
|
|
195
|
+
f"use `nsc login --rotate --profile {profile_name}` to replace its token.",
|
|
196
|
+
err=True,
|
|
197
|
+
)
|
|
198
|
+
raise typer.Exit(code=12)
|
|
199
|
+
token_input = typer.prompt("Token", hide_input=True)
|
|
200
|
+
token_for_verify = token_input.strip()
|
|
201
|
+
token_value: object
|
|
202
|
+
if store.lower() == "env":
|
|
203
|
+
if not env_var:
|
|
204
|
+
raise typer.BadParameter("--store=env requires --env-var <NAME>")
|
|
205
|
+
token_value = _env_tagged(env_var)
|
|
206
|
+
else:
|
|
207
|
+
token_value = token_for_verify
|
|
208
|
+
|
|
209
|
+
candidate = Profile(name=profile_name, url=url, token=token_for_verify) # type: ignore[arg-type]
|
|
210
|
+
try:
|
|
211
|
+
result = verify(candidate)
|
|
212
|
+
except VerifyError as exc:
|
|
213
|
+
code = _emit_auth_envelope(
|
|
214
|
+
str(exc),
|
|
215
|
+
status_code=exc.status_code,
|
|
216
|
+
user_check_status=exc.user_check_status,
|
|
217
|
+
)
|
|
218
|
+
raise typer.Exit(code=code) from exc
|
|
219
|
+
set_default = (existing_config is None) or (existing_config.default_profile is None)
|
|
220
|
+
_write_profile_entry(
|
|
221
|
+
profile=profile_name,
|
|
222
|
+
url=url,
|
|
223
|
+
token_value=token_value,
|
|
224
|
+
set_default=set_default,
|
|
225
|
+
)
|
|
226
|
+
_print_success(result.username, result.netbox_version)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _do_login_rotate(
|
|
230
|
+
profile_name: str | None,
|
|
231
|
+
store: str,
|
|
232
|
+
env_var: str | None,
|
|
233
|
+
) -> None:
|
|
234
|
+
if profile_name is None:
|
|
235
|
+
raise typer.BadParameter("--rotate requires --profile <name>")
|
|
236
|
+
profile = _resolved_profile(profile_name)
|
|
237
|
+
new_token = typer.prompt("New token", hide_input=True).strip()
|
|
238
|
+
|
|
239
|
+
candidate = Profile(
|
|
240
|
+
name=profile.name,
|
|
241
|
+
url=profile.url,
|
|
242
|
+
token=new_token,
|
|
243
|
+
verify_ssl=profile.verify_ssl,
|
|
244
|
+
schema_url=profile.schema_url,
|
|
245
|
+
timeout=profile.timeout,
|
|
246
|
+
)
|
|
247
|
+
try:
|
|
248
|
+
result = verify(candidate)
|
|
249
|
+
except VerifyError as exc:
|
|
250
|
+
code = _emit_auth_envelope(
|
|
251
|
+
str(exc),
|
|
252
|
+
status_code=exc.status_code,
|
|
253
|
+
user_check_status=exc.user_check_status,
|
|
254
|
+
)
|
|
255
|
+
raise typer.Exit(code=code) from exc
|
|
256
|
+
|
|
257
|
+
token_value: object
|
|
258
|
+
if store.lower() == "env":
|
|
259
|
+
if not env_var:
|
|
260
|
+
raise typer.BadParameter("--store=env requires --env-var <NAME>")
|
|
261
|
+
token_value = _env_tagged(env_var)
|
|
262
|
+
else:
|
|
263
|
+
token_value = new_token
|
|
264
|
+
_replace_token(profile_name, token_value)
|
|
265
|
+
_print_success(result.username, result.netbox_version)
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""`nsc profiles` — list / add / remove / rename / set-default.
|
|
2
|
+
|
|
3
|
+
Operates on the same `~/.nsc/config.yaml` as `nsc config` and the same on-disk
|
|
4
|
+
cache as the dynamic-tree handlers. `add` runs `verify()` before persisting;
|
|
5
|
+
`remove` purges the cache; `rename` moves the cache directory.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from enum import StrEnum
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Annotated
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
from ruamel.yaml.comments import CommentedMap
|
|
17
|
+
|
|
18
|
+
from nsc.auth.verify import VerifyError, verify
|
|
19
|
+
from nsc.cache.store import CacheStore
|
|
20
|
+
from nsc.cli.runtime import emit_envelope
|
|
21
|
+
from nsc.config.loader import ConfigParseError, load_config
|
|
22
|
+
from nsc.config.models import OutputFormat, Profile
|
|
23
|
+
from nsc.config.settings import default_paths
|
|
24
|
+
from nsc.config.writer import (
|
|
25
|
+
acquire_lock,
|
|
26
|
+
atomic_write,
|
|
27
|
+
dump_round_trip,
|
|
28
|
+
load_round_trip,
|
|
29
|
+
)
|
|
30
|
+
from nsc.output.errors import ErrorEnvelope, ErrorType
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _ListFormat(StrEnum):
|
|
34
|
+
TABLE = "table"
|
|
35
|
+
JSON = "json"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _config_path() -> Path:
|
|
39
|
+
return default_paths().config_file
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _cache() -> CacheStore:
|
|
43
|
+
return CacheStore(root=default_paths().cache_dir)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _emit_auth_envelope(
|
|
47
|
+
message: str, *, status_code: int | None, user_check_status: int | None
|
|
48
|
+
) -> int:
|
|
49
|
+
details: dict[str, object] = {"reason": "rejected"}
|
|
50
|
+
if user_check_status is not None:
|
|
51
|
+
details["user_check_status"] = user_check_status
|
|
52
|
+
env = ErrorEnvelope(
|
|
53
|
+
error=message,
|
|
54
|
+
type=ErrorType.AUTH,
|
|
55
|
+
status_code=status_code,
|
|
56
|
+
details=details,
|
|
57
|
+
)
|
|
58
|
+
return emit_envelope(env, output_format=OutputFormat.TABLE)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _emit_config_envelope(message: str) -> int:
|
|
62
|
+
env = ErrorEnvelope(error=message, type=ErrorType.CONFIG)
|
|
63
|
+
return emit_envelope(env, output_format=OutputFormat.TABLE)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def register(app: typer.Typer) -> None:
|
|
67
|
+
profiles_app = typer.Typer(
|
|
68
|
+
name="profiles",
|
|
69
|
+
help="Manage profiles in ~/.nsc/config.yaml.",
|
|
70
|
+
no_args_is_help=True,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
@profiles_app.command("list")
|
|
74
|
+
def list_cmd(
|
|
75
|
+
output: Annotated[
|
|
76
|
+
_ListFormat,
|
|
77
|
+
typer.Option("--output", "-o", help="table|json"),
|
|
78
|
+
] = _ListFormat.TABLE,
|
|
79
|
+
) -> None:
|
|
80
|
+
_do_list(output)
|
|
81
|
+
|
|
82
|
+
@profiles_app.command("add")
|
|
83
|
+
def add_cmd(
|
|
84
|
+
name: str = typer.Argument(...),
|
|
85
|
+
url: str = typer.Option(..., "--url"),
|
|
86
|
+
token: str = typer.Option(..., "--token"),
|
|
87
|
+
) -> None:
|
|
88
|
+
_do_add(name, url, token)
|
|
89
|
+
|
|
90
|
+
@profiles_app.command("remove")
|
|
91
|
+
def remove_cmd(
|
|
92
|
+
name: str = typer.Argument(...),
|
|
93
|
+
force: bool = typer.Option(False, "--force"),
|
|
94
|
+
) -> None:
|
|
95
|
+
_do_remove(name, force=force)
|
|
96
|
+
|
|
97
|
+
@profiles_app.command("rename")
|
|
98
|
+
def rename_cmd(
|
|
99
|
+
old: str = typer.Argument(...),
|
|
100
|
+
new: str = typer.Argument(...),
|
|
101
|
+
) -> None:
|
|
102
|
+
_do_rename(old, new)
|
|
103
|
+
|
|
104
|
+
@profiles_app.command("set-default")
|
|
105
|
+
def set_default_cmd(name: str = typer.Argument(...)) -> None:
|
|
106
|
+
_do_set_default(name)
|
|
107
|
+
|
|
108
|
+
app.add_typer(profiles_app)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _load_doc() -> CommentedMap:
|
|
112
|
+
path = _config_path()
|
|
113
|
+
doc = load_round_trip(path)
|
|
114
|
+
if "profiles" not in doc:
|
|
115
|
+
doc["profiles"] = CommentedMap()
|
|
116
|
+
return doc
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _do_list(output: _ListFormat) -> None:
|
|
120
|
+
try:
|
|
121
|
+
config = load_config(_config_path())
|
|
122
|
+
except ConfigParseError as exc:
|
|
123
|
+
code = _emit_config_envelope(str(exc))
|
|
124
|
+
raise typer.Exit(code=code) from exc
|
|
125
|
+
|
|
126
|
+
if output is _ListFormat.JSON:
|
|
127
|
+
payload = {
|
|
128
|
+
"default": config.default_profile,
|
|
129
|
+
"profiles": [{"name": p.name, "url": str(p.url)} for p in config.profiles.values()],
|
|
130
|
+
}
|
|
131
|
+
typer.echo(json.dumps(payload, indent=2))
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
if not config.profiles:
|
|
135
|
+
typer.echo("(no profiles configured)")
|
|
136
|
+
return
|
|
137
|
+
for name, profile in config.profiles.items():
|
|
138
|
+
marker = "*" if name == config.default_profile else " "
|
|
139
|
+
typer.echo(f"{marker} {name}\t{profile.url}")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _do_add(name: str, url: str, token: str) -> None:
|
|
143
|
+
path = _config_path()
|
|
144
|
+
try:
|
|
145
|
+
existing = load_config(path)
|
|
146
|
+
except ConfigParseError:
|
|
147
|
+
existing = None
|
|
148
|
+
if existing and name in existing.profiles:
|
|
149
|
+
code = _emit_config_envelope(
|
|
150
|
+
f"profile {name!r} already exists; use `nsc login --rotate --profile {name}` "
|
|
151
|
+
f"to replace its token."
|
|
152
|
+
)
|
|
153
|
+
raise typer.Exit(code=code)
|
|
154
|
+
|
|
155
|
+
candidate = Profile(name=name, url=url, token=token) # type: ignore[arg-type]
|
|
156
|
+
try:
|
|
157
|
+
result = verify(candidate)
|
|
158
|
+
except VerifyError as exc:
|
|
159
|
+
code = _emit_auth_envelope(
|
|
160
|
+
str(exc),
|
|
161
|
+
status_code=exc.status_code,
|
|
162
|
+
user_check_status=exc.user_check_status,
|
|
163
|
+
)
|
|
164
|
+
raise typer.Exit(code=code) from exc
|
|
165
|
+
|
|
166
|
+
with acquire_lock(path):
|
|
167
|
+
doc = _load_doc()
|
|
168
|
+
if existing is None or existing.default_profile is None:
|
|
169
|
+
doc.setdefault("default_profile", name)
|
|
170
|
+
profiles = doc["profiles"]
|
|
171
|
+
entry = CommentedMap()
|
|
172
|
+
entry["url"] = url
|
|
173
|
+
entry["token"] = token
|
|
174
|
+
profiles[name] = entry
|
|
175
|
+
atomic_write(path, dump_round_trip(doc))
|
|
176
|
+
typer.echo(f"✓ added profile {name!r}; authenticated as {result.username}")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _do_remove(name: str, *, force: bool) -> None:
|
|
180
|
+
path = _config_path()
|
|
181
|
+
try:
|
|
182
|
+
config = load_config(path)
|
|
183
|
+
except ConfigParseError as exc:
|
|
184
|
+
code = _emit_config_envelope(str(exc))
|
|
185
|
+
raise typer.Exit(code=code) from exc
|
|
186
|
+
if name not in config.profiles:
|
|
187
|
+
code = _emit_config_envelope(f"profile {name!r} not in config")
|
|
188
|
+
raise typer.Exit(code=code)
|
|
189
|
+
if config.default_profile == name and not force:
|
|
190
|
+
code = _emit_config_envelope(
|
|
191
|
+
f"refusing to remove default profile {name!r}; "
|
|
192
|
+
f"`nsc profiles set-default <other>` first, or pass --force."
|
|
193
|
+
)
|
|
194
|
+
raise typer.Exit(code=code)
|
|
195
|
+
|
|
196
|
+
with acquire_lock(path):
|
|
197
|
+
doc = _load_doc()
|
|
198
|
+
profiles = doc.get("profiles") or CommentedMap()
|
|
199
|
+
if name in profiles:
|
|
200
|
+
del profiles[name]
|
|
201
|
+
if doc.get("default_profile") == name:
|
|
202
|
+
del doc["default_profile"]
|
|
203
|
+
atomic_write(path, dump_round_trip(doc))
|
|
204
|
+
_cache().purge(name)
|
|
205
|
+
typer.echo(f"✓ removed profile {name!r}")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _do_rename(old: str, new: str) -> None:
|
|
209
|
+
path = _config_path()
|
|
210
|
+
try:
|
|
211
|
+
config = load_config(path)
|
|
212
|
+
except ConfigParseError as exc:
|
|
213
|
+
code = _emit_config_envelope(str(exc))
|
|
214
|
+
raise typer.Exit(code=code) from exc
|
|
215
|
+
if old not in config.profiles:
|
|
216
|
+
code = _emit_config_envelope(f"profile {old!r} not in config")
|
|
217
|
+
raise typer.Exit(code=code)
|
|
218
|
+
if new in config.profiles:
|
|
219
|
+
code = _emit_config_envelope(f"profile {new!r} already exists")
|
|
220
|
+
raise typer.Exit(code=code)
|
|
221
|
+
|
|
222
|
+
with acquire_lock(path):
|
|
223
|
+
doc = _load_doc()
|
|
224
|
+
profiles = doc["profiles"]
|
|
225
|
+
new_profiles = CommentedMap()
|
|
226
|
+
for k, v in profiles.items():
|
|
227
|
+
new_profiles[new if k == old else k] = v
|
|
228
|
+
doc["profiles"] = new_profiles
|
|
229
|
+
if doc.get("default_profile") == old:
|
|
230
|
+
doc["default_profile"] = new
|
|
231
|
+
atomic_write(path, dump_round_trip(doc))
|
|
232
|
+
try:
|
|
233
|
+
_cache().move(old, new)
|
|
234
|
+
except FileExistsError as exc:
|
|
235
|
+
typer.echo(
|
|
236
|
+
f"warning: cache for {new!r} already exists ({exc}); skipping cache move",
|
|
237
|
+
err=True,
|
|
238
|
+
)
|
|
239
|
+
typer.echo(f"✓ renamed profile {old!r} → {new!r}")
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _do_set_default(name: str) -> None:
|
|
243
|
+
path = _config_path()
|
|
244
|
+
try:
|
|
245
|
+
config = load_config(path)
|
|
246
|
+
except ConfigParseError as exc:
|
|
247
|
+
code = _emit_config_envelope(str(exc))
|
|
248
|
+
raise typer.Exit(code=code) from exc
|
|
249
|
+
if name not in config.profiles:
|
|
250
|
+
code = _emit_config_envelope(f"profile {name!r} not in config")
|
|
251
|
+
raise typer.Exit(code=code)
|
|
252
|
+
with acquire_lock(path):
|
|
253
|
+
doc = _load_doc()
|
|
254
|
+
doc["default_profile"] = name
|
|
255
|
+
atomic_write(path, dump_round_trip(doc))
|
|
256
|
+
typer.echo(f"✓ default_profile = {name}")
|