secflow-cli 0.2.2__tar.gz → 0.2.3__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 (43) hide show
  1. {secflow_cli-0.2.2/src/secflow_cli.egg-info → secflow_cli-0.2.3}/PKG-INFO +5 -5
  2. {secflow_cli-0.2.2 → secflow_cli-0.2.3}/USAGE.md +3 -3
  3. {secflow_cli-0.2.2 → secflow_cli-0.2.3}/pyproject.toml +2 -2
  4. secflow_cli-0.2.3/src/secflow_cli/__init__.py +0 -0
  5. secflow_cli-0.2.3/src/secflow_cli/access_profile_commands.py +61 -0
  6. secflow_cli-0.2.3/src/secflow_cli/account_commands.py +103 -0
  7. {secflow_cli-0.2.2 → secflow_cli-0.2.3}/src/secflow_cli/browser_login.py +23 -18
  8. {secflow_cli-0.2.2 → secflow_cli-0.2.3}/src/secflow_cli/command_support.py +53 -27
  9. {secflow_cli-0.2.2 → secflow_cli-0.2.3}/src/secflow_cli/completion.py +0 -1
  10. {secflow_cli-0.2.2 → secflow_cli-0.2.3}/src/secflow_cli/config.py +70 -30
  11. {secflow_cli-0.2.2 → secflow_cli-0.2.3}/src/secflow_cli/credentials.py +0 -4
  12. {secflow_cli-0.2.2 → secflow_cli-0.2.3}/src/secflow_cli/doctor_commands.py +5 -11
  13. {secflow_cli-0.2.2 → secflow_cli-0.2.3}/src/secflow_cli/errors.py +0 -1
  14. {secflow_cli-0.2.2 → secflow_cli-0.2.3}/src/secflow_cli/help.py +0 -1
  15. secflow_cli-0.2.3/src/secflow_cli/main.py +502 -0
  16. {secflow_cli-0.2.2 → secflow_cli-0.2.3}/src/secflow_cli/manifest_commands.py +8 -25
  17. {secflow_cli-0.2.2 → secflow_cli-0.2.3}/src/secflow_cli/namespace_commands.py +30 -40
  18. {secflow_cli-0.2.2 → secflow_cli-0.2.3}/src/secflow_cli/operator_commands.py +100 -98
  19. secflow_cli-0.2.3/src/secflow_cli/output.py +85 -0
  20. secflow_cli-0.2.3/src/secflow_cli/paging.py +59 -0
  21. {secflow_cli-0.2.2 → secflow_cli-0.2.3}/src/secflow_cli/policy_audit_commands.py +3 -5
  22. {secflow_cli-0.2.2 → secflow_cli-0.2.3}/src/secflow_cli/reference_commands.py +0 -1
  23. {secflow_cli-0.2.2 → secflow_cli-0.2.3}/src/secflow_cli/report_commands.py +11 -17
  24. secflow_cli-0.2.3/src/secflow_cli/role_commands.py +70 -0
  25. {secflow_cli-0.2.2 → secflow_cli-0.2.3}/src/secflow_cli/runtime_commands.py +17 -14
  26. {secflow_cli-0.2.2 → secflow_cli-0.2.3}/src/secflow_cli/secret_commands.py +2 -3
  27. {secflow_cli-0.2.2 → secflow_cli-0.2.3}/src/secflow_cli/session_commands.py +1 -2
  28. {secflow_cli-0.2.2 → secflow_cli-0.2.3}/src/secflow_cli/token_commands.py +1 -3
  29. {secflow_cli-0.2.2 → secflow_cli-0.2.3/src/secflow_cli.egg-info}/PKG-INFO +5 -5
  30. {secflow_cli-0.2.2 → secflow_cli-0.2.3}/src/secflow_cli.egg-info/SOURCES.txt +2 -0
  31. secflow_cli-0.2.3/src/secflow_cli.egg-info/requires.txt +2 -0
  32. secflow_cli-0.2.2/src/secflow_cli/__init__.py +0 -1
  33. secflow_cli-0.2.2/src/secflow_cli/access_profile_commands.py +0 -94
  34. secflow_cli-0.2.2/src/secflow_cli/account_commands.py +0 -143
  35. secflow_cli-0.2.2/src/secflow_cli/main.py +0 -506
  36. secflow_cli-0.2.2/src/secflow_cli/role_commands.py +0 -99
  37. secflow_cli-0.2.2/src/secflow_cli.egg-info/requires.txt +0 -2
  38. {secflow_cli-0.2.2 → secflow_cli-0.2.3}/LICENSE +0 -0
  39. {secflow_cli-0.2.2 → secflow_cli-0.2.3}/setup.cfg +0 -0
  40. {secflow_cli-0.2.2 → secflow_cli-0.2.3}/src/secflow_cli/__main__.py +0 -0
  41. {secflow_cli-0.2.2 → secflow_cli-0.2.3}/src/secflow_cli.egg-info/dependency_links.txt +0 -0
  42. {secflow_cli-0.2.2 → secflow_cli-0.2.3}/src/secflow_cli.egg-info/entry_points.txt +0 -0
  43. {secflow_cli-0.2.2 → secflow_cli-0.2.3}/src/secflow_cli.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: secflow-cli
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: SecFlow operator CLI
5
5
  License-Expression: MIT
6
6
  Project-URL: Repository, https://github.com/yeon3724/secflow
@@ -8,7 +8,7 @@ Project-URL: Issues, https://github.com/yeon3724/secflow/issues
8
8
  Requires-Python: >=3.10
9
9
  Description-Content-Type: text/markdown
10
10
  License-File: LICENSE
11
- Requires-Dist: secflow-sdk==0.1.8
11
+ Requires-Dist: secflow-sdk==0.1.9
12
12
  Requires-Dist: keyring<26,>=25
13
13
  Dynamic: license-file
14
14
 
@@ -30,7 +30,7 @@ npm install --global @intflow/secflow-cli
30
30
  secflowctl --version
31
31
  ```
32
32
 
33
- The Python distribution requires Python 3.10+. Installation automatically includes the Admin-capable `secflow-sdk==0.1.8` dependency.
33
+ The Python distribution requires Python 3.10+. Installation automatically includes the matching Admin-capable `secflow-sdk` dependency.
34
34
 
35
35
  The npm distribution includes Python and the CLI dependencies in OS-specific native packages. Initial targets: Windows x64, macOS Intel/Apple Silicon, and Linux x64 with glibc 2.35+ (Ubuntu 22.04+). Alpine/musl and Linux ARM are not npm targets yet; use the Python distribution there. Keep npm optional dependencies enabled. Installation has no postinstall script or runtime download. Native binaries are not platform-signed or notarized.
36
36
 
@@ -63,9 +63,9 @@ For automation use `SECFLOW_ADMIN_SESSION_TOKEN` supplied by your secret manager
63
63
 
64
64
  To uninstall, use `pipx uninstall secflow-cli`, `python -m pip uninstall secflow-cli`, or `npm uninstall --global @intflow/secflow-cli` as appropriate. Uninstalling does not revoke server sessions or erase profiles; log out and remove unwanted profiles first.
65
65
 
66
- ## Expanded commands in 0.2.0
66
+ ## Commands
67
67
 
68
- CLI 0.2.0 adds the commands below. Metadata catalogs, details, permission checks, and per-secret access changes require the additive operator APIs on the server. An older server can still serve existing commands. `auth status` reports expiry and capabilities on newer servers and falls back to identity-only metadata when the session endpoint is absent. Authorization or dependency failures are not treated as an old server.
68
+ Catalogs, details, permission checks, and per-secret access changes use the server's operator API. `auth status` reports the session's expiry and capabilities.
69
69
 
70
70
  | Task | Discover commands |
71
71
  | --- | --- |
@@ -16,7 +16,7 @@ npm install --global @intflow/secflow-cli
16
16
  secflowctl --version
17
17
  ```
18
18
 
19
- The Python distribution requires Python 3.10+. Installation automatically includes the Admin-capable `secflow-sdk==0.1.8` dependency.
19
+ The Python distribution requires Python 3.10+. Installation automatically includes the matching Admin-capable `secflow-sdk` dependency.
20
20
 
21
21
  The npm distribution includes Python and the CLI dependencies in OS-specific native packages. Initial targets: Windows x64, macOS Intel/Apple Silicon, and Linux x64 with glibc 2.35+ (Ubuntu 22.04+). Alpine/musl and Linux ARM are not npm targets yet; use the Python distribution there. Keep npm optional dependencies enabled. Installation has no postinstall script or runtime download. Native binaries are not platform-signed or notarized.
22
22
 
@@ -49,9 +49,9 @@ For automation use `SECFLOW_ADMIN_SESSION_TOKEN` supplied by your secret manager
49
49
 
50
50
  To uninstall, use `pipx uninstall secflow-cli`, `python -m pip uninstall secflow-cli`, or `npm uninstall --global @intflow/secflow-cli` as appropriate. Uninstalling does not revoke server sessions or erase profiles; log out and remove unwanted profiles first.
51
51
 
52
- ## Expanded commands in 0.2.0
52
+ ## Commands
53
53
 
54
- CLI 0.2.0 adds the commands below. Metadata catalogs, details, permission checks, and per-secret access changes require the additive operator APIs on the server. An older server can still serve existing commands. `auth status` reports expiry and capabilities on newer servers and falls back to identity-only metadata when the session endpoint is absent. Authorization or dependency failures are not treated as an old server.
54
+ Catalogs, details, permission checks, and per-secret access changes use the server's operator API. `auth status` reports the session's expiry and capabilities.
55
55
 
56
56
  | Task | Discover commands |
57
57
  | --- | --- |
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "secflow-cli"
7
- version = "0.2.2"
7
+ version = "0.2.3"
8
8
  description = "SecFlow operator CLI"
9
9
  readme = "USAGE.md"
10
10
  license = "MIT"
11
11
  requires-python = ">=3.10"
12
- dependencies = ["secflow-sdk==0.1.8", "keyring>=25,<26"]
12
+ dependencies = ["secflow-sdk==0.1.9", "keyring>=25,<26"]
13
13
 
14
14
  [project.scripts]
15
15
  secflowctl = "secflow_cli.main:main"
File without changes
@@ -0,0 +1,61 @@
1
+ from collections.abc import Mapping
2
+ import sys
3
+
4
+ from secflow_cli.account_commands import required_input
5
+ from secflow_cli.command_support import canonical_id, idempotency_key, is_uuid, run_mutation, typed_confirm
6
+ from secflow_cli.errors import CliError
7
+
8
+ FLAGS = ("allow_cli_login", "allow_api_session", "allow_namespace_request")
9
+ FIELDS = ("profile_id", "domain", "roles", *FLAGS, "allow_audit_read", "allow_plaintext_read")
10
+
11
+
12
+ def add_commands(groups):
13
+ commands = groups.add_parser("access-profile").add_subparsers(dest="action", required=True)
14
+ command = commands.add_parser("get")
15
+ command.add_argument("--token-stdin", action="store_true")
16
+ command = commands.add_parser("set")
17
+ command.add_argument("--profile-id")
18
+ for field in FLAGS:
19
+ command.add_argument("--" + field.replace("_", "-"), choices=("true", "false"))
20
+ command.add_argument("--token-stdin", action="store_true")
21
+ command.add_argument("--idempotency-key")
22
+ command.add_argument("--yes", action="store_true")
23
+
24
+
25
+ def prepare(args):
26
+ profile_id = required_input(args, "profile_id", "profile ID")
27
+ canonical_id(profile_id, "Supply a canonical profile UUID.")
28
+ profile = dict(profile_id=profile_id, domain="intflow.ai", roles=[], allow_audit_read=False, allow_plaintext_read=False)
29
+ for field in FLAGS:
30
+ value = required_input(args, field, field.replace("_", " "))
31
+ if value not in ("true", "false"):
32
+ raise CliError(2, "INVALID_INPUT", "Profile settings must be true or false.")
33
+ profile[field] = value == "true"
34
+ key = idempotency_key(args, "cli-access-profile-", "Supply a normalized idempotency key of 16-128 characters.")
35
+ print("Replace global default access profile: " + profile_id, file=sys.stderr)
36
+ for field in FLAGS:
37
+ print(field + ": " + str(profile[field]).lower(), file=sys.stderr)
38
+ print("Full replacement; concurrent changes are not protected by a server precondition. This does not grant roles or revoke existing credentials.", file=sys.stderr)
39
+ typed_confirm(args, profile_id, prompt="Type the profile ID to confirm: ", cancelled="Profile replacement cancelled; no mutation was sent.",
40
+ required="Supply all settings and --yes for unattended execution or when stdin carries a token.")
41
+ return dict(profile=profile, idempotency_key=key)
42
+
43
+
44
+ def execute(client, values):
45
+ data = dict(idempotency_key=values["idempotency_key"], outcome="unknown", profile=None)
46
+ return run_mutation(data, lambda: send(client, values))
47
+
48
+
49
+ def send(client, values):
50
+ result = metadata(client.put_default_access_profile(**values))
51
+ if result != values["profile"]:
52
+ raise CliError(7, "INVALID_RESPONSE", "The server did not confirm the requested profile.")
53
+ return dict(profile=result)
54
+
55
+
56
+ def metadata(value):
57
+ if (not isinstance(value, Mapping) or not is_uuid(value.get("profile_id")) or value.get("domain") != "intflow.ai" or value.get("roles") != []
58
+ or any(type(value.get(field)) is not bool for field in FLAGS)
59
+ or value.get("allow_audit_read") is not False or value.get("allow_plaintext_read") is not False):
60
+ raise CliError(7, "INVALID_RESPONSE", "The server returned an invalid default access profile.")
61
+ return {field: value[field] for field in FIELDS}
@@ -0,0 +1,103 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from collections.abc import Mapping
5
+
6
+ from secflow_cli.command_support import canonical_id, correlation, idempotency_key, preview_delete, require_confirmable, run_mutation, typed_confirm
7
+ from secflow_cli.errors import CliError
8
+ from secflow_cli.output import account_metadata
9
+
10
+
11
+ ACTIONS = ("create", "update", "reactivate", "suspend", "delete")
12
+ DESTRUCTIVE_ACTIONS = frozenset({"suspend", "delete"})
13
+ ACCOUNT_UUID = "Supply an exact canonical account UUID."
14
+
15
+
16
+ def add_commands(commands):
17
+ for action in ACTIONS:
18
+ command = commands.add_parser(action)
19
+ command.add_argument("--token-stdin", action="store_true")
20
+ command.add_argument("--idempotency-key")
21
+ if action == "create":
22
+ command.add_argument("--email")
23
+ else:
24
+ command.add_argument("--account-id")
25
+ if action in ("create", "update"):
26
+ command.add_argument("--display-name")
27
+ if action in DESTRUCTIVE_ACTIONS:
28
+ command.add_argument("--yes", action="store_true")
29
+
30
+
31
+ def required_input(args, field, label):
32
+ value = getattr(args, field)
33
+ if value is None and not args.non_interactive and not args.token_stdin and sys.stdin.isatty():
34
+ print(label + ": ", end="", file=sys.stderr, flush=True)
35
+ try:
36
+ value = input()
37
+ except EOFError:
38
+ value = None
39
+ if not isinstance(value, str) or not value.strip() or value != value.strip():
40
+ raise CliError(2, "INVALID_INPUT", "Supply a non-empty normalized " + label + ".")
41
+ return value
42
+
43
+
44
+ def prepare(args):
45
+ key = idempotency_key(args, "cli-account-", "Idempotency keys must contain 16-128 letters, digits, dots, underscores, colons, or hyphens.")
46
+ values = {"idempotency_key": key}
47
+ if args.action == "create":
48
+ values["email"] = required_input(args, "email", "email")
49
+ values.update(correlation(key))
50
+ else:
51
+ values["account_id"] = required_input(args, "account_id", "account ID")
52
+ if args.action == "update":
53
+ values["display_name"] = required_input(args, "display_name", "display name")
54
+ elif args.action == "create" and args.display_name is not None:
55
+ values["display_name"] = required_input(args, "display_name", "display name")
56
+ if args.action == "reactivate":
57
+ values["status"] = "active"
58
+ if args.action == "delete":
59
+ canonical_id(values["account_id"], ACCOUNT_UUID)
60
+ require_confirmable(args, "Supply the exact account ID and --yes for unattended execution or when stdin carries a token.")
61
+ elif args.action in DESTRUCTIVE_ACTIONS:
62
+ confirm_destructive(args, values["account_id"])
63
+ return values
64
+
65
+
66
+ def preview(args, client, values):
67
+ return preview_delete(args, client, kind="account", target=values["account_id"], operation="accounts.impact",
68
+ query={"account_id": values["account_id"]},
69
+ semantics="deletion removes the account, its sessions, SDK tokens, and direct role bindings.")
70
+
71
+
72
+ def confirm_destructive(args, account_id):
73
+ canonical_id(account_id, ACCOUNT_UUID)
74
+ print("Suspend account: " + account_id, file=sys.stderr)
75
+ print("Operation semantics, not a server impact preview: suspension revokes active Admin sessions and SDK tokens. Reactivation does not restore revoked credentials. Affected counts are unknown.", file=sys.stderr)
76
+ typed_confirm(args, account_id, prompt="Type the account ID to confirm: ", cancelled="Account operation cancelled; no mutation was sent.",
77
+ required="Supply the exact account ID and --yes for unattended execution or when stdin carries a token.")
78
+
79
+
80
+ def execute(args, client, values):
81
+ data = {"idempotency_key": values["idempotency_key"], "outcome": "unknown", "account": None}
82
+ if args.action == "delete":
83
+ data["deletion"] = None
84
+ return run_mutation(data, lambda: send(args, client, values))
85
+
86
+
87
+ def send(args, client, values):
88
+ method = {"create": client.create_account, "update": client.patch_account,
89
+ "reactivate": client.patch_account, "suspend": client.suspend_account,
90
+ "delete": client.delete_account}[args.action]
91
+ response = method(**values)
92
+ if args.action == "delete":
93
+ if (not isinstance(response, Mapping) or response.get("kind") != "account"
94
+ or response.get("id") != values["account_id"] or response.get("deleted") is not True
95
+ or not isinstance(response.get("label"), str)):
96
+ raise CliError(7, "INVALID_RESPONSE", "The server did not confirm deletion of the selected account.")
97
+ return {"deletion": {name: response[name] for name in ("kind", "id", "label", "deleted")}}
98
+ account = account_metadata(response)
99
+ if not isinstance(account.get("account_id"), str) or not account["account_id"]:
100
+ raise CliError(7, "INVALID_RESPONSE", "The server did not return confirmed account metadata.")
101
+ if args.action == "suspend" and (account["account_id"] != values["account_id"] or account.get("status") != "suspended"):
102
+ raise CliError(7, "INVALID_RESPONSE", "The server did not confirm suspension of the selected account.")
103
+ return {"account": account}
@@ -1,4 +1,3 @@
1
- """Human browser login; Auth credentials stay on the SecFlow server."""
2
1
  from __future__ import annotations
3
2
 
4
3
  import base64
@@ -55,6 +54,27 @@ def acquire_session(url, timeout, *, no_browser=False):
55
54
  raise rejected() from None
56
55
 
57
56
 
57
+ def _open_browser(verification_uri):
58
+ try:
59
+ opened = webbrowser.open(verification_uri)
60
+ except Exception:
61
+ opened = False
62
+ if not opened:
63
+ print("Open the URL above manually to continue.", file=sys.stderr)
64
+
65
+
66
+ def _issued_session(result):
67
+ token = result.get("session_token")
68
+ if not isinstance(token, str) or not re.fullmatch(r"[A-Za-z0-9_-]{32,256}", token):
69
+ raise rejected()
70
+ if any(str(UUID(result.get(field))) != result[field] for field in ("session_id", "account_id")):
71
+ raise rejected()
72
+ expiry = datetime.fromisoformat(result["expires_at"].replace("Z", "+00:00"))
73
+ if expiry.tzinfo is None or expiry <= datetime.now(timezone.utc):
74
+ raise rejected()
75
+ return {field: result[field] for field in ("session_token", "session_id", "account_id", "expires_at")}
76
+
77
+
58
78
  def _acquire_session(url, timeout, *, no_browser):
59
79
  proof = base64.urlsafe_b64encode(os.urandom(32)).rstrip(b"=").decode("ascii")
60
80
  started_at = time.monotonic()
@@ -76,12 +96,7 @@ def _acquire_session(url, timeout, *, no_browser):
76
96
  print("Open " + verification_uri, file=sys.stderr)
77
97
  print("Confirm code " + created["user_code"] + " and approve the login in your browser.", file=sys.stderr)
78
98
  if not no_browser:
79
- try:
80
- opened = webbrowser.open(verification_uri)
81
- except Exception:
82
- opened = False
83
- if not opened:
84
- print("Open the URL above manually to continue.", file=sys.stderr)
99
+ _open_browser(verification_uri)
85
100
  interval = created["interval"]
86
101
  while time.monotonic() + interval < deadline:
87
102
  time.sleep(interval)
@@ -97,15 +112,7 @@ def _acquire_session(url, timeout, *, no_browser):
97
112
  continue
98
113
  if status != 200:
99
114
  raise rejected()
100
- token = result.get("session_token")
101
- if not isinstance(token, str) or not re.fullmatch(r"[A-Za-z0-9_-]{32,256}", token):
102
- raise rejected()
103
- if any(str(UUID(result.get(field))) != result[field] for field in ("session_id", "account_id")):
104
- raise rejected()
105
- expiry = datetime.fromisoformat(result["expires_at"].replace("Z", "+00:00"))
106
- if expiry.tzinfo is None or expiry <= datetime.now(timezone.utc):
107
- raise rejected()
108
- return {field: result[field] for field in ("session_token", "session_id", "account_id", "expires_at")}
115
+ return _issued_session(result)
109
116
  raise CliError(3, "BROWSER_LOGIN_EXPIRED", "Browser login expired. Start a new login.")
110
117
 
111
118
 
@@ -119,8 +126,6 @@ def login(url, timeout, store, reference, *, no_browser=False):
119
126
  raise rejected()
120
127
  store.set(reference, session["session_token"])
121
128
  except BaseException:
122
- # Do not erase a previously stored credential. Revoke only the session
123
- # obtained by this invocation when validation/storage is unsuccessful.
124
129
  try:
125
130
  client.revoke_current_session()
126
131
  except Exception:
@@ -1,10 +1,12 @@
1
- """Shared validation for operator commands; never render request payloads."""
2
1
  import hashlib
3
2
  import re
4
3
  import sys
5
4
  from uuid import UUID, uuid4
6
5
 
6
+ from secflow_sdk import SecFlowConfigError, SecFlowSdkError
7
+
7
8
  from secflow_cli.errors import CliError
9
+ from secflow_cli.output import mapped_error
8
10
 
9
11
 
10
12
  def mutation_options(command, *, confirm=False):
@@ -14,39 +16,72 @@ def mutation_options(command, *, confirm=False):
14
16
  command.add_argument('--yes', action='store_true')
15
17
 
16
18
 
17
- def mutation_key(args):
18
- key = args.idempotency_key or 'cli-operation-' + str(uuid4())
19
- if not re.fullmatch(r'[A-Za-z0-9_.:-]{16,128}', key):
20
- raise CliError(2, 'INVALID_INPUT', 'Idempotency keys must contain 16-128 normalized characters.')
19
+ KEY_PATTERN = r'[A-Za-z0-9_.:-]{16,128}'
20
+ UNKNOWN_OUTCOME = 'Mutation completion is unknown. Inspect current state; any retry must use identical inputs and the same idempotency key.'
21
+
22
+
23
+ def idempotency_key(args, prefix, message):
24
+ key = args.idempotency_key or prefix + str(uuid4())
25
+ if not re.fullmatch(KEY_PATTERN, key):
26
+ raise CliError(2, 'INVALID_INPUT', message)
21
27
  return key
22
28
 
23
29
 
30
+ def mutation_key(args):
31
+ return idempotency_key(args, 'cli-operation-', 'Idempotency keys must contain 16-128 normalized characters.')
32
+
33
+
24
34
  def correlation(key):
25
35
  digest = hashlib.sha256(key.encode('ascii')).hexdigest()
26
36
  return dict(request_id='cli-request-' + digest, trace_id='cli-trace-' + digest)
27
37
 
28
38
 
29
- def canonical_id(value):
39
+ def is_uuid(value):
30
40
  try:
31
- if str(UUID(value)) == value:
32
- return value
33
- except (ValueError, TypeError, AttributeError):
34
- pass
35
- raise CliError(2, 'INVALID_INPUT', 'Supply a canonical resource UUID.')
41
+ return isinstance(value, str) and str(UUID(value)) == value
42
+ except ValueError:
43
+ return False
36
44
 
37
45
 
38
- def confirm(args, target):
46
+ def canonical_id(value, message='Supply a canonical resource UUID.'):
47
+ if not is_uuid(value):
48
+ raise CliError(2, 'INVALID_INPUT', message)
49
+ return value
50
+
51
+
52
+ def typed_confirm(args, target, *, prompt, cancelled, required=None):
39
53
  if args.yes:
40
54
  return
41
- if args.non_interactive or args.token_stdin or not sys.stdin.isatty():
42
- raise CliError(2, 'CONFIRMATION_REQUIRED', 'Inspect the exact target, then supply --yes to confirm this change.')
43
- print('Type the exact target to confirm: ' + target, file=sys.stderr)
55
+ if required is not None:
56
+ require_confirmable(args, required)
57
+ print(prompt, end='', file=sys.stderr, flush=True)
44
58
  try:
45
59
  answer = input()
46
60
  except EOFError:
47
61
  answer = None
48
62
  if answer != target:
49
- raise CliError(2, 'CONFIRMATION_CANCELLED', 'Cancelled; no mutation was sent.')
63
+ raise CliError(2, 'CONFIRMATION_CANCELLED', cancelled)
64
+
65
+
66
+ def confirm(args, target):
67
+ typed_confirm(args, target, prompt='Type the exact target to confirm: ' + target + '\n', cancelled='Cancelled; no mutation was sent.',
68
+ required='Inspect the exact target, then supply --yes to confirm this change.')
69
+
70
+
71
+ def run_mutation(data, send):
72
+ print('Idempotency key: ' + data['idempotency_key'], file=sys.stderr)
73
+ try:
74
+ data.update(send(), outcome='confirmed')
75
+ return data, None
76
+ except (SecFlowSdkError, CliError) as caught:
77
+ error = mapped_error(caught)
78
+ if isinstance(caught, SecFlowConfigError):
79
+ data['outcome'] = 'not_sent'
80
+ elif error.status in (2, 3, 4, 5, 6):
81
+ data['outcome'] = 'not_confirmed'
82
+ else:
83
+ error = CliError(7, 'MUTATION_OUTCOME_UNKNOWN', UNKNOWN_OUTCOME)
84
+ return data, error
50
85
 
51
86
 
52
87
  BLOCKING = frozenset({'current_account'})
@@ -58,9 +93,8 @@ def require_confirmable(args, message):
58
93
 
59
94
 
60
95
  def server_impact(client, operation, query):
61
- from secflow_sdk import SecFlowSdkError
62
96
  try:
63
- response = client._request_json('GET', '/v1/admin/operator/' + operation, query=tuple(query.items()))
97
+ response = client.get_operator_metadata(operation, parameters=query)
64
98
  except SecFlowSdkError:
65
99
  return None
66
100
  impact, blocking = response.get('impact'), response.get('blocking')
@@ -72,7 +106,6 @@ def server_impact(client, operation, query):
72
106
 
73
107
 
74
108
  def preview_delete(args, client, *, kind, target, operation, query, semantics):
75
- """Show the server's current-state impact, then require exact confirmation."""
76
109
  print('Delete ' + kind + ': ' + target, file=sys.stderr)
77
110
  impact = server_impact(client, operation, query)
78
111
  if impact is None:
@@ -82,12 +115,5 @@ def preview_delete(args, client, *, kind, target, operation, query, semantics):
82
115
  print('Server impact preview of current state (not a reservation): ' + counts, file=sys.stderr)
83
116
  if impact['blocking']:
84
117
  raise CliError(6, 'PRECONDITION_FAILED', 'The server reports a blocking precondition (' + ', '.join(impact['blocking']) + '); no mutation was sent.')
85
- if not args.yes:
86
- print('Type the ' + kind + ' to confirm: ', end='', file=sys.stderr, flush=True)
87
- try:
88
- answer = input()
89
- except EOFError:
90
- answer = None
91
- if answer != target:
92
- raise CliError(2, 'CONFIRMATION_CANCELLED', kind.capitalize() + ' deletion cancelled; no mutation was sent.')
118
+ typed_confirm(args, target, prompt='Type the ' + kind + ' to confirm: ', cancelled=kind.capitalize() + ' deletion cancelled; no mutation was sent.')
93
119
  return impact
@@ -1,4 +1,3 @@
1
- """Offline shell completion generated from the parser; never fetches server catalogs."""
2
1
  import argparse
3
2
  import json
4
3
 
@@ -51,48 +51,88 @@ def credential_ref(name: str, url: str) -> str:
51
51
  return "admin:" + hashlib.sha256((profile_name(name) + "\n" + endpoint(url)).encode()).hexdigest()
52
52
 
53
53
 
54
+ def access_denied(directory: Path) -> CliError:
55
+ return CliError(
56
+ 2,
57
+ "CONFIG_ACCESS_DENIED",
58
+ f"This account has no permission to use the CLI configuration folder {directory}. "
59
+ "Fix the folder permissions or delete it, or set SECFLOW_CLI_CONFIG_DIR to another folder.",
60
+ )
61
+
62
+
63
+ def require_readable_folder(directory: Path) -> None:
64
+ try:
65
+ os.listdir(directory)
66
+ except FileNotFoundError:
67
+ return
68
+ except PermissionError:
69
+ raise access_denied(directory) from None
70
+
71
+
72
+ def config_directory() -> Path:
73
+ override = os.environ.get("SECFLOW_CLI_CONFIG_DIR")
74
+ if override:
75
+ return Path(override).expanduser()
76
+ if sys.platform == "win32":
77
+ return Path(os.environ.get("APPDATA", str(Path.home() / "AppData/Roaming"))) / "SecFlow/cli"
78
+ if sys.platform == "darwin":
79
+ return Path.home() / "Library/Application Support/SecFlow/cli"
80
+ return Path(os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config"))) / "secflow/cli"
81
+
82
+
83
+ def read_profiles_text(path: Path) -> str | None:
84
+ try:
85
+ return path.read_text(encoding="utf-8")
86
+ except FileNotFoundError:
87
+ require_readable_folder(path.parent)
88
+ return None
89
+ except PermissionError:
90
+ raise access_denied(path.parent) from None
91
+ except OSError:
92
+ raise CliError(2, "CONFIG_READ_FAILED", f"Cannot read the CLI profile configuration file {path}.") from None
93
+
94
+
95
+ def parsed_profiles(text: str) -> dict:
96
+ try:
97
+ raw = json.loads(text)
98
+ if not isinstance(raw, dict) or set(raw) != {"version", "current", "profiles"} or raw["version"] != 1 or not isinstance(raw["profiles"], dict):
99
+ raise ValueError
100
+ for name, profile in raw["profiles"].items():
101
+ profile_name(name)
102
+ if not isinstance(profile, dict) or set(profile) != {"base_url", "credential_ref"}:
103
+ raise ValueError
104
+ if profile["credential_ref"] != credential_ref(name, endpoint(profile["base_url"])):
105
+ raise ValueError
106
+ if raw["current"] is not None and raw["current"] not in raw["profiles"]:
107
+ raise ValueError
108
+ return raw
109
+ except (ValueError, TypeError, KeyError):
110
+ raise CliError(2, "INVALID_CONFIG", "Cannot read valid CLI profile configuration.") from None
111
+
112
+
54
113
  class Profiles:
55
114
  def __init__(self):
56
- override = os.environ.get("SECFLOW_CLI_CONFIG_DIR")
57
- if override:
58
- directory = Path(override).expanduser()
59
- elif sys.platform == "win32":
60
- directory = Path(os.environ.get("APPDATA", str(Path.home() / "AppData/Roaming"))) / "SecFlow/cli"
61
- elif sys.platform == "darwin":
62
- directory = Path.home() / "Library/Application Support/SecFlow/cli"
63
- else:
64
- directory = Path(os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config"))) / "secflow/cli"
65
- self.path = directory.resolve() / "profiles.json"
66
- if any((ancestor / ".git").exists() for ancestor in self.path.parents):
67
- raise CliError(2, "INVALID_CONFIG_LOCATION", "CLI configuration must be outside a repository.")
68
- self.data = {"version": 1, "current": None, "profiles": {}}
115
+ self.path = config_directory().resolve() / "profiles.json"
69
116
  try:
70
- if self.path.exists():
71
- raw = json.loads(self.path.read_text(encoding="utf-8"))
72
- if not isinstance(raw, dict) or set(raw) != {"version", "current", "profiles"} or raw["version"] != 1 or not isinstance(raw["profiles"], dict):
73
- raise ValueError
74
- for name, profile in raw["profiles"].items():
75
- profile_name(name)
76
- if not isinstance(profile, dict) or set(profile) != {"base_url", "credential_ref"}:
77
- raise ValueError
78
- url = endpoint(profile["base_url"])
79
- if profile["credential_ref"] != credential_ref(name, url):
80
- raise ValueError
81
- if raw["current"] is not None and raw["current"] not in raw["profiles"]:
82
- raise ValueError
83
- self.data = raw
84
- except (OSError, ValueError, TypeError, KeyError):
85
- raise CliError(2, "INVALID_CONFIG", "Cannot read valid CLI profile configuration.") from None
117
+ inside_repository = any((ancestor / ".git").exists() for ancestor in self.path.parents)
118
+ except PermissionError:
119
+ raise access_denied(self.path.parent) from None
120
+ if inside_repository:
121
+ raise CliError(2, "INVALID_CONFIG_LOCATION", "CLI configuration must be outside a repository.")
122
+ text = read_profiles_text(self.path)
123
+ self.data = parsed_profiles(text) if text is not None else {"version": 1, "current": None, "profiles": {}}
86
124
 
87
125
  def save(self):
88
- self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
89
126
  temporary = None
90
127
  try:
128
+ self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
91
129
  with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=self.path.parent, delete=False) as stream:
92
130
  temporary = Path(stream.name)
93
131
  json.dump(self.data, stream, indent=2)
94
132
  stream.write("\n")
95
133
  os.replace(temporary, self.path)
134
+ except PermissionError:
135
+ raise access_denied(self.path.parent) from None
96
136
  except OSError:
97
137
  raise CliError(2, "CONFIG_WRITE_FAILED", "Cannot save CLI profile configuration.") from None
98
138
  finally:
@@ -4,7 +4,6 @@ from secflow_cli.errors import CliError
4
4
 
5
5
 
6
6
  class CredentialStore:
7
- """Only explicitly supported OS stores; never file-backed fallback plugins."""
8
7
 
9
8
  service = "secflow-cli"
10
9
  supported = frozenset({
@@ -24,9 +23,6 @@ class CredentialStore:
24
23
  import keyring
25
24
  backend = keyring.get_keyring()
26
25
  kind = (type(backend).__module__, type(backend).__name__)
27
- # Keychain and Secret Service may display OS unlock dialogs. Until
28
- # a prompt-free adapter is available, require explicit credentials
29
- # in unattended sessions on these platforms.
30
26
  if not self.allow_interaction and kind != ("keyring.backends.Windows", "WinVaultKeyring"):
31
27
  raise RuntimeError
32
28
  if kind not in self.supported or backend.priority <= 0:
@@ -1,10 +1,10 @@
1
- """Bounded connection and credential diagnostics; health never proves secret resolution."""
2
1
  from datetime import datetime, timedelta, timezone
3
2
  import sys
4
- import time
3
+ from time import sleep
5
4
 
6
5
  from secflow_sdk import SecFlowHttpError, SecFlowSdkError
7
6
  from secflow_cli.errors import CliError
7
+ from secflow_cli.output import envelope, render
8
8
 
9
9
 
10
10
  def add_commands(groups):
@@ -22,12 +22,8 @@ def check(name, status, detail):
22
22
 
23
23
  def session_checks(client, now):
24
24
  try:
25
- session = client._request_json('GET', '/v1/admin/operator/session')
25
+ session = client.get_operator_metadata('session')
26
26
  except SecFlowHttpError as error:
27
- if error.status_code == 404:
28
- client.get_current_account()
29
- return [check('session', 'ok', 'Admin session accepted.'),
30
- check('server_features', 'warn', 'The server predates operator metadata; newer commands are unavailable.')]
31
27
  if error.status_code in (401, 403):
32
28
  return [check('session', 'fail', 'The server rejected the Admin session; sign in again.')]
33
29
  raise
@@ -61,8 +57,7 @@ def diagnose(args, name, url, client_factory, health):
61
57
  return checks
62
58
 
63
59
 
64
- def run(args, name, url, client_factory, command, output):
65
- from secflow_cli.main import envelope, health, render
60
+ def run(args, name, url, client_factory, command, output, health):
66
61
  if args.watch and (not 5 <= args.interval <= 3600 or not 1 <= args.count <= 1000):
67
62
  raise CliError(2, 'INVALID_INPUT', 'Interval must be 5-3600 seconds and count 1-1000.')
68
63
  runs = args.count if args.watch else 1
@@ -70,7 +65,6 @@ def run(args, name, url, client_factory, command, output):
70
65
  cache = []
71
66
 
72
67
  def cached_client():
73
- # stdin carries the credential once; later runs reuse the same client.
74
68
  if not cache:
75
69
  try:
76
70
  cache.append(client_factory())
@@ -89,5 +83,5 @@ def run(args, name, url, client_factory, command, output):
89
83
  status = error.status if error else 0
90
84
  if index + 1 < runs:
91
85
  sys.stdout.flush()
92
- time.sleep(args.interval)
86
+ sleep(args.interval)
93
87
  return status
@@ -1,5 +1,4 @@
1
1
  class CliError(Exception):
2
- """A deliberately safe, locally authored error suitable for rendering."""
3
2
 
4
3
  def __init__(self, status: int, code: str, message: str):
5
4
  super().__init__(message)