licos-platform-cli 0.2.10__tar.gz → 0.2.12__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: licos-platform-cli
3
- Version: 0.2.10
3
+ Version: 0.2.12
4
4
  Summary: LICOS platform CLI
5
5
  Requires-Python: >=3.10
6
- Requires-Dist: licos-platform-sdk>=0.2.9
6
+ Requires-Dist: licos-platform-sdk>=0.3.0
@@ -4,11 +4,11 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "licos-platform-cli"
7
- version = "0.2.10"
7
+ version = "0.2.12"
8
8
  description = "LICOS platform CLI"
9
9
  requires-python = ">=3.10"
10
10
  dependencies = [
11
- "licos-platform-sdk>=0.2.9",
11
+ "licos-platform-sdk>=0.3.0",
12
12
  ]
13
13
 
14
14
  [project.scripts]
@@ -355,7 +355,7 @@ DATABASE_HELP = {
355
355
  }
356
356
 
357
357
 
358
- KNOWLEDGE_HELP = {
358
+ KNOWLEDGE_HELP = {
359
359
  "topic": "knowledge",
360
360
  "description": "Knowledge dataset helpers for importing text, URLs, object-storage file references, and semantic retrieval.",
361
361
  "runtime": RUNTIME_ENV,
@@ -448,14 +448,62 @@ KNOWLEDGE_HELP = {
448
448
  "example": "licos-platform knowledge delete-documents dataset-id doc-1 doc-2",
449
449
  },
450
450
  },
451
- }
451
+ }
452
+
453
+
454
+ OAUTH_HELP = {
455
+ "topic": "oauth",
456
+ "description": "Manage LICOS OAuth2/OIDC login applications from trusted project runtime code.",
457
+ "runtime": RUNTIME_ENV,
458
+ "rules": [
459
+ "Use only when the user explicitly requests LICOS platform OAuth2/OIDC login.",
460
+ "Before configure-project, confirm auto-apply versus an existing app, login audience, and all callbacks.",
461
+ "Personal users use ALL. Enterprise users explicitly choose ALL or OWNER_TENANT.",
462
+ "Generated config uses PKCE and never contains a client secret.",
463
+ "Do not rotate a secret unless the user explicitly asks for rotation.",
464
+ ],
465
+ "sdk": {
466
+ "python": "from licos_platform_sdk import oauth\napps = oauth.list_apps()\napp = oauth.get_app('app-id')",
467
+ "node": "import { oauth } from '@kmlckj/licos-platform-sdk';\nconst apps = await oauth.listApps();",
468
+ },
469
+ "commands": {
470
+ "apps": {
471
+ "description": "List OAuth2 applications manageable by the current project owner.",
472
+ "parameters": [],
473
+ "example": "licos-platform oauth apps",
474
+ },
475
+ "get": {
476
+ "description": "Get one manageable OAuth2 application.",
477
+ "parameters": [{"name": "app_id", "required": True, "description": "OAuth2 application ID."}],
478
+ "example": "licos-platform oauth get app-id",
479
+ },
480
+ "configure-project": {
481
+ "description": "After explicit confirmation, apply or update one OAuth2 app and write config/licos-oauth.json without a secret.",
482
+ "parameters": [
483
+ {"name": "--auto-apply / --app-id", "required": True, "description": "Explicit application selection mode."},
484
+ {"name": "--name", "required": True, "description": "Application display name."},
485
+ {"name": "--login-audience", "required": True, "description": "ALL or OWNER_TENANT."},
486
+ {"name": "--redirect", "required": True, "description": "Confirmed dev/prod callback; can repeat."},
487
+ {"name": "--public-base-url", "required": True, "description": "Public LICOS OAuth2 origin."},
488
+ ],
489
+ "example": "licos-platform oauth configure-project --auto-apply --name 'Project login' --login-audience ALL --redirect prod=https://app.example/auth/callback --public-base-url https://platform.example",
490
+ },
491
+ "delete": {"description": "Delete an OAuth2 application.", "parameters": [], "example": "licos-platform oauth delete app-id"},
492
+ "enable": {"description": "Enable an OAuth2 application.", "parameters": [], "example": "licos-platform oauth enable app-id"},
493
+ "disable": {"description": "Disable an OAuth2 application.", "parameters": [], "example": "licos-platform oauth disable app-id"},
494
+ "rotate-secret": {"description": "Explicitly rotate a confidential-client secret.", "parameters": [], "example": "licos-platform oauth rotate-secret app-id"},
495
+ "grants": {"description": "List application grants.", "parameters": [], "example": "licos-platform oauth grants app-id"},
496
+ "revoke-grant": {"description": "Revoke one grant.", "parameters": [], "example": "licos-platform oauth revoke-grant grant-id"},
497
+ },
498
+ }
452
499
 
453
500
 
454
- HELP_TOPICS = {
501
+ HELP_TOPICS = {
455
502
  "storage": STORAGE_HELP,
456
503
  "database": DATABASE_HELP,
457
- "knowledge": KNOWLEDGE_HELP,
458
- }
504
+ "knowledge": KNOWLEDGE_HELP,
505
+ "oauth": OAUTH_HELP,
506
+ }
459
507
 
460
508
 
461
509
  def _set_handler(parser: argparse.ArgumentParser, handler: Handler) -> None:
@@ -474,8 +522,10 @@ def _cmd_help(args: argparse.Namespace) -> dict[str, Any]:
474
522
  "licos-platform help database export-orm",
475
523
  "licos-platform help database studio-ensure",
476
524
  "licos-platform help knowledge",
477
- "licos-platform help knowledge search",
478
- ],
525
+ "licos-platform help knowledge search",
526
+ "licos-platform help oauth",
527
+ "licos-platform help oauth configure-project",
528
+ ],
479
529
  "topics": sorted(HELP_TOPICS),
480
530
  }
481
531
  topic = HELP_TOPICS[args.topic]
@@ -9,8 +9,9 @@ from licos_platform_sdk import PlatformSdkError
9
9
 
10
10
  from .database_commands import add_database_parser
11
11
  from .help_commands import add_help_parser
12
- from .knowledge_commands import add_knowledge_parser
13
- from .storage_commands import add_storage_parser
12
+ from .knowledge_commands import add_knowledge_parser
13
+ from .oauth_commands import add_oauth_parser
14
+ from .storage_commands import add_storage_parser
14
15
 
15
16
 
16
17
  def _print_json(value: Any) -> None:
@@ -31,7 +32,8 @@ def _build_parser() -> argparse.ArgumentParser:
31
32
  add_help_parser(subparsers)
32
33
  add_storage_parser(subparsers)
33
34
  add_database_parser(subparsers)
34
- add_knowledge_parser(subparsers)
35
+ add_knowledge_parser(subparsers)
36
+ add_oauth_parser(subparsers)
35
37
  return parser
36
38
 
37
39
 
@@ -0,0 +1,216 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Any, Callable
8
+ from urllib.parse import urlsplit, urlunsplit
9
+
10
+ from licos_platform_sdk import oauth
11
+
12
+
13
+ Handler = Callable[[argparse.Namespace], Any]
14
+ DEFAULT_SCOPES = ["openid", "profile", "email"]
15
+
16
+
17
+ def _set_handler(parser: argparse.ArgumentParser, handler: Handler) -> None:
18
+ parser.set_defaults(handler=handler)
19
+
20
+
21
+ def _parse_http_url(value: str, *, option: str) -> str:
22
+ candidate = value.strip()
23
+ parsed = urlsplit(candidate)
24
+ if parsed.scheme not in {"http", "https"} or not parsed.netloc:
25
+ raise argparse.ArgumentTypeError(f"{option} must be an absolute http(s) URL")
26
+ if parsed.fragment:
27
+ raise argparse.ArgumentTypeError(f"{option} must not contain a fragment")
28
+ return candidate
29
+
30
+
31
+ def _parse_redirect(value: str) -> dict[str, str]:
32
+ environment = "prod"
33
+ redirect_uri = value
34
+ prefix, separator, remainder = value.partition("=")
35
+ if separator:
36
+ environment = prefix.strip().lower()
37
+ redirect_uri = remainder
38
+ if environment not in {"dev", "prod"}:
39
+ raise argparse.ArgumentTypeError("redirect environment must be dev or prod")
40
+ return {
41
+ "environment": environment,
42
+ "redirectUri": _parse_http_url(redirect_uri, option="--redirect"),
43
+ }
44
+
45
+
46
+ def _public_base_url(value: str) -> str:
47
+ parsed = urlsplit(_parse_http_url(value, option="--public-base-url"))
48
+ path = parsed.path.rstrip("/")
49
+ if path.endswith("/api/v1"):
50
+ path = path[: -len("/api/v1")]
51
+ return urlunsplit((parsed.scheme, parsed.netloc, path, "", "")).rstrip("/")
52
+
53
+
54
+ def _project_root() -> Path:
55
+ return Path(os.environ.get("LICOS_PROJECT_PATH") or Path.cwd()).resolve()
56
+
57
+
58
+ def _config_path(output: str) -> Path:
59
+ root = _project_root()
60
+ candidate = Path(output)
61
+ resolved = (candidate if candidate.is_absolute() else root / candidate).resolve()
62
+ try:
63
+ resolved.relative_to(root)
64
+ except ValueError as exc:
65
+ raise argparse.ArgumentTypeError("--out must stay inside LICOS_PROJECT_PATH") from exc
66
+ return resolved
67
+
68
+
69
+ def _without_secret(value: Any) -> Any:
70
+ if isinstance(value, dict):
71
+ return {
72
+ key: _without_secret(item)
73
+ for key, item in value.items()
74
+ if "secret" not in key.lower().replace("_", "")
75
+ }
76
+ if isinstance(value, list):
77
+ return [_without_secret(item) for item in value]
78
+ return value
79
+
80
+
81
+ def _cmd_apps(args: argparse.Namespace) -> Any:
82
+ return _without_secret(oauth.list_apps(page=args.page, page_size=args.page_size))
83
+
84
+
85
+ def _cmd_get(args: argparse.Namespace) -> Any:
86
+ return _without_secret(oauth.get_app(args.app_id))
87
+
88
+
89
+ def _cmd_delete(args: argparse.Namespace) -> Any:
90
+ return oauth.delete_app(args.app_id)
91
+
92
+
93
+ def _cmd_set_enabled(args: argparse.Namespace) -> Any:
94
+ return _without_secret(oauth.set_enabled(args.app_id, args.enabled))
95
+
96
+
97
+ def _cmd_rotate_secret(args: argparse.Namespace) -> Any:
98
+ return oauth.rotate_secret(args.app_id)
99
+
100
+
101
+ def _cmd_grants(args: argparse.Namespace) -> Any:
102
+ return oauth.list_grants(args.app_id)
103
+
104
+
105
+ def _cmd_revoke_grant(args: argparse.Namespace) -> Any:
106
+ return oauth.revoke_grant(args.grant_id)
107
+
108
+
109
+ def _cmd_configure_project(args: argparse.Namespace) -> dict[str, Any]:
110
+ public_base_url = args.public_base_url or os.environ.get("LICOS_OAUTH_PUBLIC_BASE_URL")
111
+ if not public_base_url:
112
+ raise argparse.ArgumentTypeError(
113
+ "--public-base-url or LICOS_OAUTH_PUBLIC_BASE_URL is required"
114
+ )
115
+ normalized_public_base_url = _public_base_url(public_base_url)
116
+ path = _config_path(args.out)
117
+ app = oauth.ensure_project_app(
118
+ app_id=args.app_id,
119
+ name=args.name,
120
+ description=args.description,
121
+ login_audience=args.login_audience,
122
+ redirect_uri_configs=args.redirects,
123
+ allowed_scopes=args.scopes or DEFAULT_SCOPES,
124
+ )
125
+ app_id = str(app.get("id") or "").strip()
126
+ if not app_id:
127
+ raise argparse.ArgumentTypeError("platform OAuth2 app response is missing id")
128
+ client_id = str(app.get("clientId") or "").strip()
129
+ if not client_id:
130
+ raise argparse.ArgumentTypeError("platform OAuth2 app response is missing clientId")
131
+
132
+ config = {
133
+ "provider": "licos-oauth2",
134
+ "appId": app_id,
135
+ "appCode": app.get("appCode"),
136
+ "clientId": client_id,
137
+ "loginAudience": args.login_audience,
138
+ "scopes": args.scopes or DEFAULT_SCOPES,
139
+ "redirectUriConfigs": args.redirects,
140
+ "pkce": True,
141
+ "publicBaseUrl": normalized_public_base_url,
142
+ "endpoints": {
143
+ "authorization": "/oauth2/authorize",
144
+ "token": "/oauth2/token",
145
+ "userinfo": "/oauth2/userinfo",
146
+ "revoke": "/oauth2/revoke",
147
+ "discovery": "/.well-known/openid-configuration",
148
+ "jwks": "/oauth2/jwks",
149
+ },
150
+ }
151
+ path.parent.mkdir(parents=True, exist_ok=True)
152
+ path.write_text(json.dumps(config, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
153
+ return {
154
+ "app": _without_secret(app),
155
+ "configPath": str(path),
156
+ "selectionMode": "existing" if args.app_id else "auto-apply",
157
+ }
158
+
159
+
160
+ def add_oauth_parser(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
161
+ parser = subparsers.add_parser("oauth", help="Manage LICOS platform OAuth2 applications")
162
+ commands = parser.add_subparsers(dest="oauth_command", required=True)
163
+
164
+ apps = commands.add_parser("apps", help="List manageable OAuth2 applications")
165
+ apps.add_argument("--page", type=int, default=1)
166
+ apps.add_argument("--page-size", type=int, default=100)
167
+ _set_handler(apps, _cmd_apps)
168
+
169
+ get_app = commands.add_parser("get", help="Get one OAuth2 application")
170
+ get_app.add_argument("app_id")
171
+ _set_handler(get_app, _cmd_get)
172
+
173
+ configure = commands.add_parser(
174
+ "configure-project",
175
+ help="Configure a confirmed OAuth2 application and write public PKCE project settings",
176
+ )
177
+ selection = configure.add_mutually_exclusive_group(required=True)
178
+ selection.add_argument("--auto-apply", action="store_true", help="Create or update the app for this project ID")
179
+ selection.add_argument("--app-id", default=None, help="Use and update an existing manageable OAuth2 app")
180
+ configure.add_argument("--name", required=True)
181
+ configure.add_argument("--description", default=None)
182
+ configure.add_argument("--login-audience", required=True, choices=["ALL", "OWNER_TENANT"])
183
+ configure.add_argument(
184
+ "--redirect",
185
+ action="append",
186
+ dest="redirects",
187
+ type=_parse_redirect,
188
+ required=True,
189
+ help="Callback as dev=URL, prod=URL, or URL (defaults to prod); can repeat",
190
+ )
191
+ configure.add_argument("--scope", action="append", dest="scopes", default=None, help="OAuth2 scope; can repeat")
192
+ configure.add_argument("--public-base-url", default=None, help="Public LICOS OAuth2 origin; /api/v1 is removed")
193
+ configure.add_argument("--out", default="config/licos-oauth.json", help="Config path under LICOS_PROJECT_PATH")
194
+ _set_handler(configure, _cmd_configure_project)
195
+
196
+ delete = commands.add_parser("delete", help="Delete an OAuth2 application")
197
+ delete.add_argument("app_id")
198
+ _set_handler(delete, _cmd_delete)
199
+
200
+ for name, enabled in (("enable", True), ("disable", False)):
201
+ command = commands.add_parser(name, help=f"{name.capitalize()} an OAuth2 application")
202
+ command.add_argument("app_id")
203
+ command.set_defaults(enabled=enabled)
204
+ _set_handler(command, _cmd_set_enabled)
205
+
206
+ rotate = commands.add_parser("rotate-secret", help="Explicitly rotate and print a new confidential-client secret")
207
+ rotate.add_argument("app_id")
208
+ _set_handler(rotate, _cmd_rotate_secret)
209
+
210
+ grants = commands.add_parser("grants", help="List grants for an OAuth2 application")
211
+ grants.add_argument("app_id")
212
+ _set_handler(grants, _cmd_grants)
213
+
214
+ revoke = commands.add_parser("revoke-grant", help="Revoke one OAuth2 grant")
215
+ revoke.add_argument("grant_id")
216
+ _set_handler(revoke, _cmd_revoke_grant)
@@ -14,7 +14,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "licos-platform-sdk
14
14
  sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
15
15
 
16
16
  from licos_platform_cli import main as cli_main
17
- from licos_platform_cli import database_commands, knowledge_commands, storage_commands
17
+ from licos_platform_cli import database_commands, knowledge_commands, oauth_commands, storage_commands
18
18
  from licos_platform_sdk import ApiError
19
19
 
20
20
 
@@ -46,7 +46,7 @@ class PlatformCliTests(unittest.TestCase):
46
46
  self.assertEqual(code, 0)
47
47
  self.assertEqual(stderr, "")
48
48
  payload = json.loads(stdout)
49
- self.assertEqual(payload["topics"], ["database", "knowledge", "storage"])
49
+ self.assertEqual(payload["topics"], ["database", "knowledge", "oauth", "storage"])
50
50
  self.assertIn("licos-platform help storage", payload["usage"])
51
51
 
52
52
  def test_structured_help_storage_command_describes_parameters(self) -> None:
@@ -140,13 +140,175 @@ class PlatformCliTests(unittest.TestCase):
140
140
  self.assertEqual(stdout, "")
141
141
  self.assertIn("invalid choice", stderr)
142
142
 
143
- def test_sdk_errors_return_cli_failure(self) -> None:
143
+ def test_sdk_errors_return_cli_failure(self) -> None:
144
144
  with mock.patch.object(storage_commands.storage, "summary", side_effect=ApiError("platform failed")):
145
145
  code, stdout, stderr = run_cli("storage", "summary")
146
146
 
147
147
  self.assertEqual(code, 1)
148
148
  self.assertEqual(stdout, "")
149
- self.assertIn("platform failed", stderr)
149
+ self.assertIn("platform failed", stderr)
150
+
151
+ def test_oauth_configure_project_requires_explicit_app_selection(self) -> None:
152
+ code, stdout, stderr = run_cli(
153
+ "oauth",
154
+ "configure-project",
155
+ "--name",
156
+ "Project login",
157
+ "--login-audience",
158
+ "ALL",
159
+ "--redirect",
160
+ "prod=https://app.example/callback",
161
+ "--public-base-url",
162
+ "https://platform.example",
163
+ )
164
+
165
+ self.assertEqual(code, 2)
166
+ self.assertEqual(stdout, "")
167
+ self.assertIn("--auto-apply", stderr)
168
+ self.assertIn("--app-id", stderr)
169
+
170
+ def test_oauth_configure_project_requires_explicit_audience(self) -> None:
171
+ code, stdout, stderr = run_cli(
172
+ "oauth",
173
+ "configure-project",
174
+ "--auto-apply",
175
+ "--name",
176
+ "Project login",
177
+ "--redirect",
178
+ "prod=https://app.example/callback",
179
+ "--public-base-url",
180
+ "https://platform.example",
181
+ )
182
+
183
+ self.assertEqual(code, 2)
184
+ self.assertEqual(stdout, "")
185
+ self.assertIn("--login-audience", stderr)
186
+
187
+ def test_oauth_list_never_prints_secret_fields(self) -> None:
188
+ result = {
189
+ "list": [
190
+ {
191
+ "id": "app-1",
192
+ "secret": "secret-1",
193
+ "clientSecret": "secret-2",
194
+ "client_secret": "secret-3",
195
+ }
196
+ ]
197
+ }
198
+ with mock.patch.object(oauth_commands.oauth, "list_apps", return_value=result):
199
+ code, stdout, stderr = run_cli("oauth", "apps")
200
+
201
+ self.assertEqual(code, 0)
202
+ self.assertEqual(stderr, "")
203
+ self.assertEqual(json.loads(stdout), {"list": [{"id": "app-1"}]})
204
+
205
+ def test_oauth_configure_existing_app_writes_pkce_config_without_secret(self) -> None:
206
+ app = {
207
+ "id": "app-1",
208
+ "appCode": "project-project-1",
209
+ "clientId": "client-1",
210
+ "secret": "must-not-be-written",
211
+ }
212
+ with tempfile.TemporaryDirectory() as tmp:
213
+ with mock.patch.dict(os.environ, {"LICOS_PROJECT_PATH": tmp}, clear=False):
214
+ with mock.patch.object(oauth_commands.oauth, "ensure_project_app", return_value=app) as ensure:
215
+ code, stdout, stderr = run_cli(
216
+ "oauth",
217
+ "configure-project",
218
+ "--app-id",
219
+ "app-1",
220
+ "--name",
221
+ "Project login",
222
+ "--login-audience",
223
+ "OWNER_TENANT",
224
+ "--redirect",
225
+ "dev=http://localhost:3000/auth/callback",
226
+ "--redirect",
227
+ "prod=https://app.example/auth/callback",
228
+ "--public-base-url",
229
+ "https://platform.example/api/v1",
230
+ )
231
+
232
+ self.assertEqual(code, 0)
233
+ self.assertEqual(stderr, "")
234
+ payload = json.loads(stdout)
235
+ config_path = Path(payload["configPath"])
236
+ config = json.loads(config_path.read_text(encoding="utf-8"))
237
+ self.assertEqual(config["clientId"], "client-1")
238
+ self.assertEqual(config["loginAudience"], "OWNER_TENANT")
239
+ self.assertEqual(config["publicBaseUrl"], "https://platform.example")
240
+ self.assertTrue(config["pkce"])
241
+ self.assertNotIn("secret", config)
242
+ ensure.assert_called_once_with(
243
+ app_id="app-1",
244
+ name="Project login",
245
+ description=None,
246
+ login_audience="OWNER_TENANT",
247
+ redirect_uri_configs=[
248
+ {"environment": "dev", "redirectUri": "http://localhost:3000/auth/callback"},
249
+ {"environment": "prod", "redirectUri": "https://app.example/auth/callback"},
250
+ ],
251
+ allowed_scopes=["openid", "profile", "email"],
252
+ )
253
+
254
+ def test_oauth_configure_auto_apply_uses_environment_public_base(self) -> None:
255
+ app = {
256
+ "id": "app-2",
257
+ "appCode": "project-project-1",
258
+ "clientId": "client-2",
259
+ }
260
+ with tempfile.TemporaryDirectory() as tmp:
261
+ with mock.patch.dict(
262
+ os.environ,
263
+ {"LICOS_PROJECT_PATH": tmp, "LICOS_OAUTH_PUBLIC_BASE_URL": "https://platform.example/api/v1"},
264
+ clear=False,
265
+ ):
266
+ with mock.patch.object(oauth_commands.oauth, "ensure_project_app", return_value=app) as ensure:
267
+ code, stdout, stderr = run_cli(
268
+ "oauth",
269
+ "configure-project",
270
+ "--auto-apply",
271
+ "--name",
272
+ "Project login",
273
+ "--login-audience",
274
+ "ALL",
275
+ "--redirect",
276
+ "https://app.example/auth/callback",
277
+ )
278
+
279
+ self.assertEqual(code, 0)
280
+ self.assertEqual(stderr, "")
281
+ config = json.loads((Path(tmp) / "config" / "licos-oauth.json").read_text(encoding="utf-8"))
282
+ self.assertEqual(config["publicBaseUrl"], "https://platform.example")
283
+ self.assertEqual(config["redirectUriConfigs"][0]["environment"], "prod")
284
+ self.assertIsNone(ensure.call_args.kwargs["app_id"])
285
+ self.assertEqual(json.loads(stdout)["selectionMode"], "auto-apply")
286
+
287
+ def test_oauth_invalid_output_path_does_not_mutate_platform(self) -> None:
288
+ with tempfile.TemporaryDirectory() as tmp:
289
+ outside = str(Path(tmp).parent / "outside.json")
290
+ with mock.patch.dict(os.environ, {"LICOS_PROJECT_PATH": tmp}, clear=False):
291
+ with mock.patch.object(oauth_commands.oauth, "ensure_project_app") as ensure:
292
+ code, stdout, stderr = run_cli(
293
+ "oauth",
294
+ "configure-project",
295
+ "--auto-apply",
296
+ "--name",
297
+ "Project login",
298
+ "--login-audience",
299
+ "ALL",
300
+ "--redirect",
301
+ "prod=https://app.example/auth/callback",
302
+ "--public-base-url",
303
+ "https://platform.example",
304
+ "--out",
305
+ outside,
306
+ )
307
+
308
+ self.assertEqual(code, 2)
309
+ self.assertEqual(stdout, "")
310
+ self.assertIn("must stay inside", stderr)
311
+ ensure.assert_not_called()
150
312
 
151
313
  def test_database_query_forwards_runtime_options(self) -> None:
152
314
  with mock.patch.object(database_commands.database, "query", return_value={"rows": []}) as query: