frontier-runner 0.2.3__tar.gz → 0.2.4__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 (82) hide show
  1. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/CHANGELOG.md +6 -0
  2. {frontier_runner-0.2.3/frontier_runner.egg-info → frontier_runner-0.2.4}/PKG-INFO +4 -4
  3. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/README.md +3 -3
  4. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier/__init__.py +1 -1
  5. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier/cli.py +105 -8
  6. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier/context.py +11 -0
  7. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier/credentials.py +202 -5
  8. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier/onboard/commands.py +22 -5
  9. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier/onboard/profile_commands.py +6 -11
  10. frontier_runner-0.2.4/frontier/onboard/project_commands.py +310 -0
  11. frontier_runner-0.2.4/frontier/onboard/saas.py +594 -0
  12. frontier_runner-0.2.4/frontier/onboard/user_auth.py +182 -0
  13. {frontier_runner-0.2.3 → frontier_runner-0.2.4/frontier_runner.egg-info}/PKG-INFO +4 -4
  14. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier_runner.egg-info/SOURCES.txt +2 -0
  15. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/pyproject.toml +1 -1
  16. frontier_runner-0.2.3/frontier/onboard/saas.py +0 -276
  17. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/LICENSE +0 -0
  18. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/MANIFEST.in +0 -0
  19. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier/__main__.py +0 -0
  20. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier/adapters/__init__.py +0 -0
  21. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier/adapters/base.py +0 -0
  22. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier/adapters/bigquery.py +0 -0
  23. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier/adapters/databricks.py +0 -0
  24. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier/adapters/postgres.py +0 -0
  25. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier/adapters/redshift.py +0 -0
  26. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier/adapters/snowflake.py +0 -0
  27. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier/api.py +0 -0
  28. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier/artifacts.py +0 -0
  29. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier/cdc/__init__.py +0 -0
  30. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier/cdc/config.py +0 -0
  31. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier/cdc/consume.py +0 -0
  32. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier/cdc/normalize.py +0 -0
  33. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier/cdc/prove.py +0 -0
  34. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier/cdc/route.py +0 -0
  35. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier/cdc/store.py +0 -0
  36. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier/cdc/streams.py +0 -0
  37. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier/cdc/upload.py +0 -0
  38. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier/certification.py +0 -0
  39. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier/comment.py +0 -0
  40. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier/compare.py +0 -0
  41. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier/config.py +0 -0
  42. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier/dbt_artifacts.py +0 -0
  43. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier/environment.py +0 -0
  44. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier/errors.py +0 -0
  45. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier/execute.py +0 -0
  46. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier/filter_v1.py +0 -0
  47. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier/frontier.py +0 -0
  48. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier/github.py +0 -0
  49. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier/hashing.py +0 -0
  50. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier/identity.py +0 -0
  51. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier/impact.py +0 -0
  52. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier/local_config.py +0 -0
  53. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier/mutation.py +0 -0
  54. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier/onboard/__init__.py +0 -0
  55. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier/onboard/constants.py +0 -0
  56. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier/onboard/demo.py +0 -0
  57. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier/onboard/detect.py +0 -0
  58. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier/onboard/discover.py +0 -0
  59. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier/onboard/doctor.py +0 -0
  60. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier/onboard/github.py +0 -0
  61. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier/onboard/gitignore.py +0 -0
  62. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier/onboard/hashkey.py +0 -0
  63. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier/onboard/permissions.py +0 -0
  64. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier/onboard/prompt.py +0 -0
  65. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier/onboard/routes.py +0 -0
  66. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier/onboard/versions.py +0 -0
  67. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier/profiles.py +0 -0
  68. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier/progress.py +0 -0
  69. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier/proof.py +0 -0
  70. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier/repair.py +0 -0
  71. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier/semantic.py +0 -0
  72. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier/snapshot.py +0 -0
  73. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier/snowflake.py +0 -0
  74. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier/snowflake_sql.py +0 -0
  75. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier/sql_fingerprint.py +0 -0
  76. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier/validation.py +0 -0
  77. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier/warehouse.py +0 -0
  78. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier_runner.egg-info/dependency_links.txt +0 -0
  79. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier_runner.egg-info/entry_points.txt +0 -0
  80. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier_runner.egg-info/requires.txt +0 -0
  81. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/frontier_runner.egg-info/top_level.txt +0 -0
  82. {frontier_runner-0.2.3 → frontier_runner-0.2.4}/setup.cfg +0 -0
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.4] — 2026-09-27
11
+
12
+ - CLI browser login (`frontier login`) uses a PKCE device-code flow and stores a short-lived user token separately from project API keys.
13
+ - `frontier project create` / `frontier profile create --create-project` create a SaaS project with user authorization, issue a project key, and bind a named Frontier profile without printing the key.
14
+
10
15
  ## [0.2.3] — 2026-09-27
11
16
 
12
17
  - Named Frontier profiles: local execution contexts that bind a SaaS project credential, API origin, optional dbt target, and optional profiles.yml path without replacing `dbt_project.yml` `profile:`.
@@ -107,6 +112,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
107
112
  - SaaS commands resolve stored keychain credentials through one shared
108
113
  resolver instead of requiring `FRONTIER_API_KEY` in the environment.
109
114
 
115
+ [0.2.4]: https://github.com/jadsamara/frontier-runner/compare/v0.2.3...v0.2.4
110
116
  [0.2.3]: https://github.com/jadsamara/frontier-runner/compare/v0.2.2...v0.2.3
111
117
  [0.2.2]: https://github.com/jadsamara/frontier-runner/compare/v0.2.1...v0.2.2
112
118
  [0.2.1]: https://github.com/jadsamara/frontier-runner/compare/v0.2.0...v0.2.1
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: frontier-runner
3
- Version: 0.2.3
3
+ Version: 0.2.4
4
4
  Summary: Customer-side Frontier CLI for dbt + Snowflake or BigQuery + GitHub impact assessments.
5
5
  Author: Frontier
6
6
  License: MIT License
@@ -92,7 +92,7 @@ Until the package is on PyPI, install the GitHub Release wheel for a version
92
92
  tag. You do not need a commit SHA:
93
93
 
94
94
  ```bash
95
- pipx install \ "frontier-runner[snowflake] @ https://github.com/jadsamara/frontier-runner/releases/download/v0.2.3/frontier_runner-0.2.3-py3-none-any.whl"
95
+ pipx install \ "frontier-runner[snowflake] @ https://github.com/jadsamara/frontier-runner/releases/download/v0.2.4/frontier_runner-0.2.4-py3-none-any.whl"
96
96
  pipx inject frontier-runner "snowflake-connector-python>=3.12,<4"
97
97
  ```
98
98
 
@@ -248,9 +248,9 @@ workflow uses `FRONTIER_BLOCKING=false` unless you pass `--blocking`.
248
248
  Pin an immutable released version:
249
249
 
250
250
  ```bash
251
- pip install "frontier-runner[snowflake]==0.2.3"
251
+ pip install "frontier-runner[snowflake]==0.2.4"
252
252
  # or
253
- pip install "frontier-runner[bigquery]==0.2.3"
253
+ pip install "frontier-runner[bigquery]==0.2.4"
254
254
  ```
255
255
 
256
256
  Until PyPI trusted publishing is reviewed and live, install the GitHub Release
@@ -34,7 +34,7 @@ Until the package is on PyPI, install the GitHub Release wheel for a version
34
34
  tag. You do not need a commit SHA:
35
35
 
36
36
  ```bash
37
- pipx install \ "frontier-runner[snowflake] @ https://github.com/jadsamara/frontier-runner/releases/download/v0.2.3/frontier_runner-0.2.3-py3-none-any.whl"
37
+ pipx install \ "frontier-runner[snowflake] @ https://github.com/jadsamara/frontier-runner/releases/download/v0.2.4/frontier_runner-0.2.4-py3-none-any.whl"
38
38
  pipx inject frontier-runner "snowflake-connector-python>=3.12,<4"
39
39
  ```
40
40
 
@@ -190,9 +190,9 @@ workflow uses `FRONTIER_BLOCKING=false` unless you pass `--blocking`.
190
190
  Pin an immutable released version:
191
191
 
192
192
  ```bash
193
- pip install "frontier-runner[snowflake]==0.2.3"
193
+ pip install "frontier-runner[snowflake]==0.2.4"
194
194
  # or
195
- pip install "frontier-runner[bigquery]==0.2.3"
195
+ pip install "frontier-runner[bigquery]==0.2.4"
196
196
  ```
197
197
 
198
198
  Until PyPI trusted publishing is reviewed and live, install the GitHub Release
@@ -1,3 +1,3 @@
1
1
  """Customer-side Frontier runner."""
2
2
 
3
- __version__ = "0.2.3"
3
+ __version__ = "0.2.4"
@@ -148,6 +148,14 @@ from frontier.onboard.profile_commands import (
148
148
  cmd_profile_status,
149
149
  cmd_profile_use,
150
150
  )
151
+ from frontier.onboard.project_commands import (
152
+ cmd_organization_list,
153
+ cmd_project_create,
154
+ cmd_project_key_create,
155
+ cmd_project_list,
156
+ cmd_project_status,
157
+ )
158
+ from frontier.onboard.user_auth import cmd_auth_user_logout, cmd_auth_user_status
151
159
  from frontier.onboard.constants import DEFAULT_API_URL
152
160
  from frontier.onboard.routes import (
153
161
  derive_source_route,
@@ -2681,15 +2689,23 @@ def _add_project_dir(parser: argparse.ArgumentParser) -> None:
2681
2689
  dest="project_dir_opt",
2682
2690
  help="dbt project directory (same as the positional path)",
2683
2691
  )
2684
- _add_profile_option(parser)
2692
+ _add_profile_option(parser, inherit_parent=True)
2685
2693
 
2686
2694
 
2687
- def _add_profile_option(parser: argparse.ArgumentParser) -> None:
2695
+ def _add_profile_option(
2696
+ parser: argparse.ArgumentParser,
2697
+ *,
2698
+ inherit_parent: bool = False,
2699
+ ) -> None:
2700
+ # Nested subparsers overwrite a parent dest when they declare the same
2701
+ # option with default=None. SUPPRESS lets `frontier --profile b …` win
2702
+ # over a later subparser default, while still accepting `--profile` after
2703
+ # the subcommand.
2688
2704
  parser.add_argument(
2689
2705
  "--profile",
2690
2706
  dest="frontier_profile",
2691
2707
  metavar="NAME",
2692
- default=None,
2708
+ default=argparse.SUPPRESS if inherit_parent else None,
2693
2709
  help=FRONTIER_PROFILE_HELP,
2694
2710
  )
2695
2711
 
@@ -2979,14 +2995,14 @@ def build_parser() -> argparse.ArgumentParser:
2979
2995
  signup.add_argument("--api-url", help="SaaS origin")
2980
2996
  signup.set_defaults(func=cmd_signup)
2981
2997
 
2982
- login = sub.add_parser("login", help="Store a project API key in the OS keychain")
2998
+ login = sub.add_parser("login", help="Sign in as a Frontier user, or store a project API key")
2983
2999
  login.add_argument("--api-key", action="store_true", help="Paste a project API key (hidden input). Never pass the key as a positional argument.")
2984
3000
  login.add_argument("--api-url", help="SaaS origin")
2985
- _add_profile_option(login)
3001
+ _add_profile_option(login, inherit_parent=True)
2986
3002
  login.set_defaults(func=cmd_login)
2987
3003
 
2988
3004
  logout = sub.add_parser("logout", help="Remove stored Frontier API credentials")
2989
- _add_profile_option(logout)
3005
+ _add_profile_option(logout, inherit_parent=True)
2990
3006
  logout.add_argument(
2991
3007
  "--remove-metadata",
2992
3008
  action="store_true",
@@ -2997,8 +3013,15 @@ def build_parser() -> argparse.ArgumentParser:
2997
3013
  auth = sub.add_parser("auth", help="Show authentication status")
2998
3014
  auth_sub = auth.add_subparsers(dest="auth_command", required=True)
2999
3015
  auth_status = auth_sub.add_parser("status", help="Show whether the CLI is authenticated")
3000
- _add_profile_option(auth_status)
3016
+ _add_profile_option(auth_status, inherit_parent=True)
3017
+ auth_status.add_argument("--api-url", help="SaaS origin")
3001
3018
  auth_status.set_defaults(func=cmd_auth_status)
3019
+ auth_user_status = auth_sub.add_parser("user-status", help="Show Frontier user authorization")
3020
+ auth_user_status.add_argument("--api-url", help="SaaS origin")
3021
+ auth_user_status.set_defaults(func=cmd_auth_user_status)
3022
+ auth_user_logout = auth_sub.add_parser("user-logout", help="Revoke stored Frontier user authorization")
3023
+ auth_user_logout.add_argument("--api-url", help="SaaS origin")
3024
+ auth_user_logout.set_defaults(func=cmd_auth_user_logout)
3002
3025
 
3003
3026
  profile = sub.add_parser(
3004
3027
  "profile",
@@ -3008,6 +3031,7 @@ def build_parser() -> argparse.ArgumentParser:
3008
3031
  "dbt `profile:` in dbt_project.yml."
3009
3032
  ),
3010
3033
  )
3034
+ _add_profile_option(profile, inherit_parent=True)
3011
3035
  profile_sub = profile.add_subparsers(dest="profile_command", required=True)
3012
3036
  profile_create = profile_sub.add_parser(
3013
3037
  "create",
@@ -3022,6 +3046,14 @@ def build_parser() -> argparse.ArgumentParser:
3022
3046
  action="store_true",
3023
3047
  help="Prompt securely for the project API key (never pass the key as an argument)",
3024
3048
  )
3049
+ profile_create.add_argument(
3050
+ "--create-project",
3051
+ metavar="NAME",
3052
+ help="Create a SaaS project with this name, issue a key, and bind this Frontier profile",
3053
+ )
3054
+ profile_create.add_argument("--organization", help="Organization ID or unambiguous slug")
3055
+ profile_create.add_argument("--use", action="store_true", help="Select this Frontier profile for the current repository")
3056
+ profile_create.add_argument("--non-interactive", action="store_true", help="Do not prompt; require --organization when multiple orgs exist")
3025
3057
  profile_create.add_argument("--force", action="store_true", help="Replace an existing Frontier profile")
3026
3058
  profile_create.add_argument("--yes", action="store_true", help="Accept the default API origin")
3027
3059
  _add_project_dir(profile_create)
@@ -3048,6 +3080,61 @@ def build_parser() -> argparse.ArgumentParser:
3048
3080
  _add_project_dir(profile_remove)
3049
3081
  profile_remove.set_defaults(func=cmd_profile_remove)
3050
3082
 
3083
+ organization = sub.add_parser("organization", help="List organizations for the signed-in Frontier user")
3084
+ organization_sub = organization.add_subparsers(dest="organization_command", required=True)
3085
+ organization_list = organization_sub.add_parser("list", help="List organizations you can access")
3086
+ organization_list.add_argument("--api-url", help="SaaS origin")
3087
+ organization_list.set_defaults(func=cmd_organization_list)
3088
+
3089
+ project = sub.add_parser("project", help="Create and inspect Frontier SaaS projects")
3090
+ project_sub = project.add_subparsers(dest="project_command", required=True)
3091
+ project_list = project_sub.add_parser("list", help="List accessible Frontier projects")
3092
+ project_list.add_argument("--api-url", help="SaaS origin")
3093
+ project_list.set_defaults(func=cmd_project_list)
3094
+ project_status = project_sub.add_parser("status", help="Show one accessible Frontier project")
3095
+ project_status.add_argument("project", help="Project name or ID")
3096
+ project_status.add_argument("--api-url", help="SaaS origin")
3097
+ project_status.set_defaults(func=cmd_project_status)
3098
+ project_create = project_sub.add_parser("create", help="Create a Frontier SaaS project")
3099
+ project_create.add_argument("name", help="SaaS project name")
3100
+ project_create.add_argument("--organization", help="Organization ID or unambiguous slug")
3101
+ project_create.add_argument("--profile", dest="profile", help="Create this named Frontier profile after issuing a key")
3102
+ project_create.add_argument("--target", help="Default dbt target for the Frontier profile")
3103
+ project_create.add_argument("--profiles", help="Optional profiles.yml path for the Frontier profile")
3104
+ project_create.add_argument("--api-url", help="SaaS origin")
3105
+ project_create.add_argument("--warehouse", help="Warehouse type (default snowflake)")
3106
+ project_create.add_argument("--no-profile", action="store_true", help="Do not create a named Frontier profile")
3107
+ project_create.add_argument("--use", action="store_true", help="Select the new Frontier profile for this repository")
3108
+ project_create.add_argument("--non-interactive", action="store_true", help="Do not prompt")
3109
+ project_create.add_argument("--force", action="store_true", help="Replace an existing Frontier profile")
3110
+ project_create.add_argument(
3111
+ "project_dir",
3112
+ nargs="?",
3113
+ default=".",
3114
+ help="dbt project directory (default: current directory)",
3115
+ )
3116
+ project_create.add_argument(
3117
+ "--project-dir",
3118
+ dest="project_dir_opt",
3119
+ help="dbt project directory for --use",
3120
+ )
3121
+ project_create.set_defaults(func=cmd_project_create)
3122
+ project_key = project_sub.add_parser("key", help="Manage project API keys")
3123
+ project_key_sub = project_key.add_subparsers(dest="project_key_command", required=True)
3124
+ project_key_create = project_key_sub.add_parser("create", help="Issue a replacement project API key")
3125
+ project_key_create.add_argument("project", help="Project name or ID")
3126
+ project_key_create.add_argument("--profile", dest="profile", help="Store the key on this Frontier profile")
3127
+ project_key_create.add_argument("--target", help="Default dbt target when creating profile metadata")
3128
+ project_key_create.add_argument("--profiles", help="Optional profiles.yml path")
3129
+ project_key_create.add_argument("--api-url", help="SaaS origin")
3130
+ project_key_create.add_argument("--non-interactive", action="store_true", help="Do not prompt")
3131
+ project_key_create.add_argument(
3132
+ "--project-dir",
3133
+ dest="project_dir_opt",
3134
+ help="dbt project directory",
3135
+ )
3136
+ project_key_create.set_defaults(func=cmd_project_key_create)
3137
+
3051
3138
  discover = sub.add_parser(
3052
3139
  "discover",
3053
3140
  help="Derive and upload a generated semantic configuration from dbt artifacts",
@@ -3275,7 +3362,17 @@ def main(argv: Sequence[str] | None = None) -> int:
3275
3362
  parser = build_parser()
3276
3363
  args = parser.parse_args(argv)
3277
3364
  command = getattr(args, "command", None)
3278
- if command not in {"update-check", "doctor", "login", "logout", "signup", "profile"}:
3365
+ if command not in {
3366
+ "update-check",
3367
+ "doctor",
3368
+ "login",
3369
+ "logout",
3370
+ "signup",
3371
+ "profile",
3372
+ "organization",
3373
+ "project",
3374
+ "auth",
3375
+ }:
3279
3376
  from frontier.onboard.constants import DEFAULT_API_URL
3280
3377
 
3281
3378
  maybe_version_notice(str(getattr(args, "api_url", None) or DEFAULT_API_URL))
@@ -56,6 +56,17 @@ SOURCE_ENV_PROFILE = "env"
56
56
  SOURCE_LOCAL = "local"
57
57
  SOURCE_NONE = "none"
58
58
 
59
+ PROFILE_SOURCE_LABELS = {
60
+ SOURCE_FLAG: "--profile",
61
+ SOURCE_ENV_PROFILE: "FRONTIER_PROFILE",
62
+ SOURCE_LOCAL: "local selection",
63
+ SOURCE_NONE: "none (legacy)",
64
+ }
65
+
66
+
67
+ def profile_source_label(source: str) -> str:
68
+ return PROFILE_SOURCE_LABELS.get(source, source)
69
+
59
70
 
60
71
  @dataclass(frozen=True)
61
72
  class ExecutionContext:
@@ -17,6 +17,8 @@ SOURCE_KEYRING = "keyring"
17
17
  SOURCE_FILE = "file"
18
18
  SOURCE_DEMO = "FRONTIER_DEMO_API_KEY"
19
19
  PROFILE_KEYRING_PREFIX = "profile:"
20
+ USER_KEYRING_PREFIX = "user:"
21
+ SOURCE_USER_ENV = "FRONTIER_USER_TOKEN"
20
22
 
21
23
 
22
24
  @dataclass(frozen=True)
@@ -69,6 +71,11 @@ def keyring_username(profile_name: str | None = None) -> str:
69
71
  return f"{PROFILE_KEYRING_PREFIX}{name}"
70
72
 
71
73
 
74
+ def user_keyring_username(api_url: str) -> str:
75
+ origin = (api_url or "").strip().rstrip("/").lower()
76
+ return f"{USER_KEYRING_PREFIX}{origin or 'default'}"
77
+
78
+
72
79
  def key_prefix(api_key: str) -> str:
73
80
  key = api_key.strip()
74
81
  if len(key) <= 12:
@@ -248,17 +255,37 @@ def _read_file_store(path: Path) -> tuple[StoredCredentials | None, dict[str, St
248
255
  return parsed, {}
249
256
 
250
257
 
258
+ def _read_user_store(path: Path) -> dict[str, "StoredUserAuthorization"]:
259
+ if not path.is_file():
260
+ return {}
261
+ try:
262
+ payload = json.loads(path.read_text())
263
+ except json.JSONDecodeError:
264
+ return {}
265
+ if not isinstance(payload, dict) or not isinstance(payload.get("users"), dict):
266
+ return {}
267
+ users: dict[str, StoredUserAuthorization] = {}
268
+ for origin, item in payload["users"].items():
269
+ if not isinstance(item, dict):
270
+ continue
271
+ parsed = _user_from_mapping(item, source=SOURCE_FILE)
272
+ if parsed:
273
+ users[str(origin)] = parsed
274
+ return users
275
+
276
+
251
277
  def _write_file_store(
252
278
  path: Path,
253
279
  *,
254
280
  legacy: StoredCredentials | None,
255
281
  named: dict[str, StoredCredentials],
256
282
  ) -> None:
257
- if not named:
258
- if legacy is None:
259
- if path.is_file():
260
- path.unlink()
261
- return
283
+ users = _read_user_store(path)
284
+ if not named and legacy is None and not users:
285
+ if path.is_file():
286
+ path.unlink()
287
+ return
288
+ if not named and not users and legacy is not None:
262
289
  _write_fallback(path, json.dumps(legacy.to_payload()))
263
290
  return
264
291
  payload: dict[str, Any] = {
@@ -267,6 +294,10 @@ def _write_file_store(
267
294
  }
268
295
  if legacy is not None:
269
296
  payload["legacy"] = legacy.to_payload()
297
+ if users:
298
+ payload["users"] = {origin: auth.to_payload() for origin, auth in users.items()}
299
+ if not payload["profiles"]:
300
+ payload.pop("profiles")
270
301
  _write_fallback(path, json.dumps(payload))
271
302
 
272
303
 
@@ -409,3 +440,169 @@ def delete_credentials(
409
440
 
410
441
  def redact_credentials_dict(value: dict[str, Any]) -> dict[str, Any]:
411
442
  return redact(value)
443
+
444
+
445
+ @dataclass(frozen=True)
446
+ class StoredUserAuthorization:
447
+ api_url: str
448
+ access_token: str
449
+ email: str
450
+ user_id: str
451
+ expires_at: str
452
+ source: str = SOURCE_KEYRING
453
+ issuer: str = ""
454
+ audience: str = "frontier-cli"
455
+
456
+ def to_payload(self) -> dict[str, str]:
457
+ return {
458
+ "apiUrl": self.api_url,
459
+ "accessToken": self.access_token,
460
+ "email": self.email,
461
+ "userId": self.user_id,
462
+ "expiresAt": self.expires_at,
463
+ "issuer": self.issuer or self.api_url,
464
+ "audience": self.audience,
465
+ }
466
+
467
+ def expired(self) -> bool:
468
+ value = (self.expires_at or "").strip()
469
+ if not value:
470
+ return True
471
+ try:
472
+ from datetime import datetime, timezone
473
+
474
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
475
+ except ValueError:
476
+ return True
477
+ now = datetime.now(tz=timezone.utc)
478
+ if parsed.tzinfo is None:
479
+ parsed = parsed.replace(tzinfo=timezone.utc)
480
+ return parsed <= now
481
+
482
+
483
+ def _user_from_mapping(payload: dict[str, Any], *, source: str) -> StoredUserAuthorization | None:
484
+ token = str(payload.get("accessToken") or "").strip()
485
+ url = str(payload.get("apiUrl") or "").strip()
486
+ if not token or not url:
487
+ return None
488
+ return StoredUserAuthorization(
489
+ api_url=url.rstrip("/"),
490
+ access_token=token,
491
+ email=str(payload.get("email") or "").strip(),
492
+ user_id=str(payload.get("userId") or "").strip(),
493
+ expires_at=str(payload.get("expiresAt") or "").strip(),
494
+ source=source,
495
+ issuer=str(payload.get("issuer") or url).strip().rstrip("/"),
496
+ audience=str(payload.get("audience") or "frontier-cli").strip() or "frontier-cli",
497
+ )
498
+
499
+
500
+ def _write_user_file_store(path: Path, users: dict[str, StoredUserAuthorization]) -> None:
501
+ legacy, named = _read_file_store(path)
502
+ if not named and legacy is None and not users:
503
+ if path.is_file():
504
+ path.unlink()
505
+ return
506
+ if not named and not users and legacy is not None:
507
+ _write_fallback(path, json.dumps(legacy.to_payload()))
508
+ return
509
+ payload: dict[str, Any] = {
510
+ "version": 1,
511
+ "profiles": {name: creds.to_payload() for name, creds in named.items()},
512
+ "users": {origin: auth.to_payload() for origin, auth in users.items()},
513
+ }
514
+ if legacy is not None:
515
+ payload["legacy"] = legacy.to_payload()
516
+ if not payload["profiles"]:
517
+ payload.pop("profiles")
518
+ if not payload["users"]:
519
+ payload.pop("users")
520
+ _write_fallback(path, json.dumps(payload))
521
+
522
+
523
+ def save_user_authorization(
524
+ auth: StoredUserAuthorization,
525
+ *,
526
+ fallback_path: Path | None = None,
527
+ ) -> str:
528
+ encoded = json.dumps(auth.to_payload())
529
+ if _set_keyring_entry(user_keyring_username(auth.api_url), encoded):
530
+ return SOURCE_KEYRING
531
+ path = fallback_path or default_fallback_path()
532
+ users = _read_user_store(path)
533
+ stored = StoredUserAuthorization(
534
+ api_url=auth.api_url,
535
+ access_token=auth.access_token,
536
+ email=auth.email,
537
+ user_id=auth.user_id,
538
+ expires_at=auth.expires_at,
539
+ source=SOURCE_FILE,
540
+ issuer=auth.issuer,
541
+ audience=auth.audience,
542
+ )
543
+ users[auth.api_url.rstrip("/").lower()] = stored
544
+ _write_user_file_store(path, users)
545
+ mode = path.stat().st_mode & 0o777
546
+ if mode != 0o600:
547
+ raise ConfigError("PROJECT_KEY_STORAGE_FAILED: credential file must be mode 0600.")
548
+ return SOURCE_FILE
549
+
550
+
551
+ def load_user_authorization(
552
+ api_url: str,
553
+ *,
554
+ fallback_path: Path | None = None,
555
+ ) -> StoredUserAuthorization | None:
556
+ env_token = (os.environ.get(SOURCE_USER_ENV) or "").strip()
557
+ env_url = (os.environ.get("FRONTIER_API_URL") or "").strip() or api_url
558
+ if env_token:
559
+ auth = StoredUserAuthorization(
560
+ api_url=env_url.rstrip("/"),
561
+ access_token=env_token,
562
+ email="",
563
+ user_id="",
564
+ expires_at="9999-12-31T00:00:00+00:00",
565
+ source=SOURCE_USER_ENV,
566
+ )
567
+ return None if auth.expired() else auth
568
+ origin = api_url.rstrip("/")
569
+ raw = _get_keyring_entry(user_keyring_username(origin))
570
+ if raw:
571
+ try:
572
+ payload = json.loads(raw)
573
+ except json.JSONDecodeError:
574
+ payload = None
575
+ parsed = _user_from_mapping(payload, source=SOURCE_KEYRING) if isinstance(payload, dict) else None
576
+ if parsed and not parsed.expired():
577
+ return parsed
578
+ path = fallback_path or default_fallback_path()
579
+ stored = _read_user_store(path).get(origin.lower())
580
+ if stored and not stored.expired():
581
+ return stored
582
+ return None
583
+
584
+
585
+ def delete_user_authorization(
586
+ api_url: str,
587
+ *,
588
+ fallback_path: Path | None = None,
589
+ ) -> None:
590
+ origin = api_url.rstrip("/")
591
+ _delete_keyring_entry(user_keyring_username(origin))
592
+ path = fallback_path or default_fallback_path()
593
+ users = _read_user_store(path)
594
+ users.pop(origin.lower(), None)
595
+ _write_user_file_store(path, users)
596
+
597
+
598
+ def user_authorization_backend(api_url: str) -> str | None:
599
+ origin = api_url.rstrip("/")
600
+ raw = _get_keyring_entry(user_keyring_username(origin))
601
+ if raw:
602
+ return SOURCE_KEYRING
603
+ path = default_fallback_path()
604
+ if origin.lower() in _read_user_store(path):
605
+ return SOURCE_FILE
606
+ if (os.environ.get(SOURCE_USER_ENV) or "").strip():
607
+ return SOURCE_USER_ENV
608
+ return None
@@ -94,10 +94,14 @@ def cmd_login(args: Any) -> int:
94
94
  profile_name = (getattr(args, "frontier_profile", None) or "").strip() or None
95
95
  api_url = (getattr(args, "api_url", None) or DEFAULT_API_URL).rstrip("/")
96
96
  if not getattr(args, "api_key", False):
97
- print("Browser login is not available in this runner version.")
98
- print("Run: frontier login --api-key")
99
- print(f"Create a key at {api_url}/settings after signing in.")
100
- return 1
97
+ from frontier.onboard.user_auth import complete_user_login
98
+
99
+ return complete_user_login(
100
+ args,
101
+ sleeper=getattr(args, "_sleep", None),
102
+ opener=getattr(args, "_open_browser", None),
103
+ clock=getattr(args, "_clock", None),
104
+ )
101
105
  reader = getattr(args, "_getpass", None) or getpass.getpass
102
106
  try:
103
107
  api_key = str(reader("Project API key: ")).strip()
@@ -179,10 +183,22 @@ def cmd_logout(args: Any) -> int:
179
183
 
180
184
 
181
185
  def cmd_auth_status(args: Any) -> int:
186
+ from frontier.credentials import load_user_authorization
187
+ from frontier.onboard.user_auth import require_user_authorization
188
+
189
+ api_url = (getattr(args, "api_url", None) or DEFAULT_API_URL).rstrip("/")
190
+ user_line = "User: not authenticated"
191
+ if load_user_authorization(api_url):
192
+ try:
193
+ user = require_user_authorization(api_url)
194
+ user_line = f"User: {user.email or user.user_id} (expires {user.expires_at})"
195
+ except InstallError:
196
+ user_line = "User: stored authorization was rejected"
197
+ print(user_line)
182
198
  ctx = resolve_execution_context(args, _project_dir_from_args(args), require_credential=False)
183
199
  creds = ctx.creds
184
200
  if not creds:
185
- print("Not authenticated")
201
+ print("Project key: not authenticated")
186
202
  if ctx.profile_name:
187
203
  print(f"Frontier profile: {ctx.profile_name}")
188
204
  print(f"Next: frontier login --api-key --profile {ctx.profile_name}")
@@ -199,6 +215,7 @@ def cmd_auth_status(args: Any) -> int:
199
215
  print("Next: frontier login --api-key")
200
216
  return 1
201
217
  print("Authenticated")
218
+ print("Project key: authenticated")
202
219
  if ctx.profile_name:
203
220
  print(f"Frontier profile: {ctx.profile_name}")
204
221
  print(f"Organization: {identity.organization or creds.organization or '(unknown)'}")
@@ -6,11 +6,8 @@ from pathlib import Path
6
6
  from typing import Any
7
7
 
8
8
  from frontier.context import (
9
- SOURCE_FLAG,
10
- SOURCE_ENV_PROFILE,
11
- SOURCE_LOCAL,
12
- SOURCE_NONE,
13
9
  ensure_named_profile_ids,
10
+ profile_source_label,
14
11
  resolve_execution_context,
15
12
  )
16
13
  from frontier.credentials import (
@@ -171,6 +168,10 @@ def _maybe_validate_target(project_dir: Path, target: str | None, profiles: str
171
168
 
172
169
 
173
170
  def cmd_profile_create(args: Any) -> int:
171
+ if str(getattr(args, "create_project", "") or "").strip():
172
+ from frontier.onboard.project_commands import cmd_profile_create_project
173
+
174
+ return cmd_profile_create_project(args)
174
175
  name = validate_profile_name(str(getattr(args, "name", "") or ""))
175
176
  api_url = _resolve_create_api_url(args)
176
177
  want_prompt = bool(getattr(args, "api_key", False))
@@ -306,14 +307,8 @@ def _identity_status(ctx) -> str:
306
307
  def cmd_profile_status(args: Any) -> int:
307
308
  project_dir = _project_dir(args)
308
309
  ctx = resolve_execution_context(args, project_dir, honor_local_config_target=False, require_credential=False)
309
- source_label = {
310
- SOURCE_FLAG: "--profile",
311
- SOURCE_ENV_PROFILE: "FRONTIER_PROFILE",
312
- SOURCE_LOCAL: "local selection",
313
- SOURCE_NONE: "none (legacy)",
314
- }.get(ctx.profile_source, ctx.profile_source)
315
310
  print(f"Frontier profile: {ctx.profile_name or '(none)'}")
316
- print(f"Selection source: {source_label}")
311
+ print(f"Selection source: {profile_source_label(ctx.profile_source)}")
317
312
  print(
318
313
  f"Organization: {format_organization_identity(ctx.organization_name, ctx.organization_id)}"
319
314
  )