orchestra-cli 0.3.4__tar.gz → 0.4.0__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 (55) hide show
  1. {orchestra_cli-0.3.4 → orchestra_cli-0.4.0}/PKG-INFO +19 -4
  2. {orchestra_cli-0.3.4 → orchestra_cli-0.4.0}/README.md +18 -3
  3. {orchestra_cli-0.3.4 → orchestra_cli-0.4.0}/orchestra_cli/src/build_pipeline.py +5 -12
  4. {orchestra_cli-0.3.4 → orchestra_cli-0.4.0}/orchestra_cli/src/cli.py +2 -0
  5. {orchestra_cli-0.3.4 → orchestra_cli-0.4.0}/orchestra_cli/src/create_pipeline.py +3 -3
  6. {orchestra_cli-0.3.4 → orchestra_cli-0.4.0}/orchestra_cli/src/delete_pipeline.py +3 -3
  7. {orchestra_cli-0.3.4 → orchestra_cli-0.4.0}/orchestra_cli/src/fetch_pipelines.py +3 -3
  8. {orchestra_cli-0.3.4 → orchestra_cli-0.4.0}/orchestra_cli/src/get_pipeline.py +3 -3
  9. {orchestra_cli-0.3.4 → orchestra_cli-0.4.0}/orchestra_cli/src/import_pipeline.py +3 -3
  10. orchestra_cli-0.4.0/orchestra_cli/src/login.py +162 -0
  11. orchestra_cli-0.4.0/orchestra_cli/src/login_page.html +42 -0
  12. {orchestra_cli-0.3.4 → orchestra_cli-0.4.0}/orchestra_cli/src/migrate_pipeline.py +7 -7
  13. {orchestra_cli-0.3.4 → orchestra_cli-0.4.0}/orchestra_cli/src/run_pipeline.py +15 -23
  14. {orchestra_cli-0.3.4 → orchestra_cli-0.4.0}/orchestra_cli/src/task_logs.py +9 -11
  15. {orchestra_cli-0.3.4 → orchestra_cli-0.4.0}/orchestra_cli/src/update_pipeline.py +4 -4
  16. {orchestra_cli-0.3.4 → orchestra_cli-0.4.0}/orchestra_cli/src/validate_pipeline.py +8 -4
  17. {orchestra_cli-0.3.4 → orchestra_cli-0.4.0}/orchestra_cli/tests/conftest.py +11 -0
  18. {orchestra_cli-0.3.4 → orchestra_cli-0.4.0}/orchestra_cli/tests/test_create_pipeline.py +12 -0
  19. orchestra_cli-0.4.0/orchestra_cli/tests/test_login.py +180 -0
  20. {orchestra_cli-0.3.4 → orchestra_cli-0.4.0}/orchestra_cli/tests/test_run_pipeline.py +120 -0
  21. {orchestra_cli-0.3.4 → orchestra_cli-0.4.0}/orchestra_cli/tests/test_validate_pipeline.py +41 -0
  22. orchestra_cli-0.4.0/orchestra_cli/tests/utils/test_api.py +194 -0
  23. orchestra_cli-0.4.0/orchestra_cli/utils/api.py +150 -0
  24. orchestra_cli-0.4.0/orchestra_cli/utils/credentials.py +49 -0
  25. {orchestra_cli-0.3.4 → orchestra_cli-0.4.0}/orchestra_cli/utils/pipeline_lookup.py +1 -2
  26. {orchestra_cli-0.3.4 → orchestra_cli-0.4.0}/orchestra_cli/utils/yaml_loader.py +27 -1
  27. {orchestra_cli-0.3.4 → orchestra_cli-0.4.0}/orchestra_cli.egg-info/PKG-INFO +19 -4
  28. {orchestra_cli-0.3.4 → orchestra_cli-0.4.0}/orchestra_cli.egg-info/SOURCES.txt +5 -0
  29. {orchestra_cli-0.3.4 → orchestra_cli-0.4.0}/pyproject.toml +4 -1
  30. orchestra_cli-0.3.4/orchestra_cli/utils/api.py +0 -65
  31. {orchestra_cli-0.3.4 → orchestra_cli-0.4.0}/orchestra_cli/src/__init__.py +0 -0
  32. {orchestra_cli-0.3.4 → orchestra_cli-0.4.0}/orchestra_cli/src/pipeline_upsert.py +0 -0
  33. {orchestra_cli-0.3.4 → orchestra_cli-0.4.0}/orchestra_cli/tests/__init__.py +0 -0
  34. {orchestra_cli-0.3.4 → orchestra_cli-0.4.0}/orchestra_cli/tests/test_build_pipeline.py +0 -0
  35. {orchestra_cli-0.3.4 → orchestra_cli-0.4.0}/orchestra_cli/tests/test_cli.py +0 -0
  36. {orchestra_cli-0.3.4 → orchestra_cli-0.4.0}/orchestra_cli/tests/test_delete_pipeline.py +0 -0
  37. {orchestra_cli-0.3.4 → orchestra_cli-0.4.0}/orchestra_cli/tests/test_fetch_pipelines.py +0 -0
  38. {orchestra_cli-0.3.4 → orchestra_cli-0.4.0}/orchestra_cli/tests/test_get_pipeline.py +0 -0
  39. {orchestra_cli-0.3.4 → orchestra_cli-0.4.0}/orchestra_cli/tests/test_import_pipeline.py +0 -0
  40. {orchestra_cli-0.3.4 → orchestra_cli-0.4.0}/orchestra_cli/tests/test_migrate_pipeline.py +0 -0
  41. {orchestra_cli-0.3.4 → orchestra_cli-0.4.0}/orchestra_cli/tests/test_pipeline_selector.py +0 -0
  42. {orchestra_cli-0.3.4 → orchestra_cli-0.4.0}/orchestra_cli/tests/test_task_logs.py +0 -0
  43. {orchestra_cli-0.3.4 → orchestra_cli-0.4.0}/orchestra_cli/tests/test_update_pipeline.py +0 -0
  44. {orchestra_cli-0.3.4 → orchestra_cli-0.4.0}/orchestra_cli/tests/utils/__init__.py +0 -0
  45. {orchestra_cli-0.3.4 → orchestra_cli-0.4.0}/orchestra_cli/tests/utils/test_git.py +0 -0
  46. {orchestra_cli-0.3.4 → orchestra_cli-0.4.0}/orchestra_cli/utils/constants.py +0 -0
  47. {orchestra_cli-0.3.4 → orchestra_cli-0.4.0}/orchestra_cli/utils/git.py +0 -0
  48. {orchestra_cli-0.3.4 → orchestra_cli-0.4.0}/orchestra_cli/utils/pipeline_selector.py +0 -0
  49. {orchestra_cli-0.3.4 → orchestra_cli-0.4.0}/orchestra_cli/utils/pipeline_update.py +0 -0
  50. {orchestra_cli-0.3.4 → orchestra_cli-0.4.0}/orchestra_cli/utils/styling.py +0 -0
  51. {orchestra_cli-0.3.4 → orchestra_cli-0.4.0}/orchestra_cli.egg-info/dependency_links.txt +0 -0
  52. {orchestra_cli-0.3.4 → orchestra_cli-0.4.0}/orchestra_cli.egg-info/entry_points.txt +0 -0
  53. {orchestra_cli-0.3.4 → orchestra_cli-0.4.0}/orchestra_cli.egg-info/requires.txt +0 -0
  54. {orchestra_cli-0.3.4 → orchestra_cli-0.4.0}/orchestra_cli.egg-info/top_level.txt +0 -0
  55. {orchestra_cli-0.3.4 → orchestra_cli-0.4.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: orchestra-cli
3
- Version: 0.3.4
3
+ Version: 0.4.0
4
4
  Summary: Orchestra CLI. Perform operations with Orchestra locally.
5
5
  Author-email: Orchestra Technologies <support@getorchestra.io>
6
6
  Requires-Python: >=3.10
@@ -34,7 +34,7 @@ pipx install orchestra-cli
34
34
 
35
35
  ## Environment variables
36
36
 
37
- - `ORCHESTRA_API_KEY`: Required for actions that call the API (`pipeline import`, `pipeline new`, `pipeline update`, `pipeline migrate`, `pipeline get`, `pipeline list`, `pipeline delete`, `pipeline run`, `pipeline build`).
37
+ - `ORCHESTRA_API_KEY`: Credential for actions that call the API (`pipeline import`, `pipeline new`, `pipeline update`, `pipeline migrate`, `pipeline get`, `pipeline list`, `pipeline delete`, `pipeline run`, `pipeline build`). Not needed once you have run `orchestra login`: a login takes precedence, and the key is used only when you are not logged in or your login has expired.
38
38
  - `BASE_URL`: Optional. Override the default Orchestra host (`https://app.getorchestra.io`) for non‑production/testing.
39
39
 
40
40
  ## Command structure
@@ -43,6 +43,7 @@ Commands follow a `noun verb` shape. The current nouns are `pipeline` and `task`
43
43
 
44
44
  | Command | Description |
45
45
  | ------------------------------------ | ------------------------------------------------------------------------------------------- |
46
+ | `orchestra login` | Log in through your browser so later commands need no API key. |
46
47
  | `orchestra pipeline validate <file>` | Validate a pipeline YAML locally against the Orchestra API schema. |
47
48
  | `orchestra pipeline import` | Register a pipeline YAML (from a git repo) with Orchestra under an alias. |
48
49
  | `orchestra pipeline get` | Fetch one pipeline by path, alias, or pipeline ID. |
@@ -55,6 +56,8 @@ Commands follow a `noun verb` shape. The current nouns are `pipeline` and `task`
55
56
  | `orchestra pipeline build` | Validate local YAML, create or update a draft pipeline, and start that draft version. |
56
57
  | `orchestra task logs` | Fetch or follow logs for a single task run. |
57
58
 
59
+ Commands that read a local pipeline file (`pipeline validate`, `import`, `new`, `update`, `build`, and `run --path` with `--task`) accept `.yaml`, `.yml` and `.oml` files; any other extension is rejected. `.oml` files are read as YAML, so only OML written in YAML or JSON syntax is supported. OML block terminators (`.`) and embeds (`%sql ... %%`) fail with an `Invalid YAML` error.
60
+
58
61
  Use `orchestra --help`, `orchestra <noun> --help`, or `orchestra <noun> <verb> --help` for built-in help.
59
62
 
60
63
  ### Legacy command names
@@ -77,7 +80,7 @@ New code and documentation should prefer the noun/verb form.
77
80
 
78
81
  ## pipeline validate
79
82
 
80
- Validate a YAML file against the Orchestra API schema.
83
+ Validate a pipeline file against the Orchestra API schema.
81
84
 
82
85
  ```bash
83
86
  orchestra pipeline validate path/to/pipeline.yaml
@@ -87,7 +90,7 @@ orchestra-cli pipeline validate path/to/pipeline.yaml
87
90
 
88
91
  Options
89
92
 
90
- - `file` (positional): Path to the YAML file to validate.
93
+ - `file` (positional): Path to the `.yaml`, `.yml` or `.oml` file to validate.
91
94
 
92
95
  Behavior
93
96
 
@@ -110,6 +113,18 @@ pipeline:
110
113
 
111
114
  ---
112
115
 
116
+ ## login
117
+
118
+ Log in through your browser instead of pasting an API key. The token is cached in `~/.orchestra/credentials.json` (readable only by you) and refreshed automatically, so later commands need no `ORCHESTRA_API_KEY`.
119
+
120
+ ```bash
121
+ orchestra login
122
+ ```
123
+
124
+ Logins are per host: a login made with `BASE_URL` pointing at another environment is only used against that environment. CI and scripts, where nobody is logged in, keep using `ORCHESTRA_API_KEY`.
125
+
126
+ ---
127
+
113
128
  ## pipeline import
114
129
 
115
130
  Create (import) a pipeline in Orchestra by referencing a YAML file inside a git repository. The command infers your repository host/provider, default branch, and YAML path relative to the repo root.
@@ -20,7 +20,7 @@ pipx install orchestra-cli
20
20
 
21
21
  ## Environment variables
22
22
 
23
- - `ORCHESTRA_API_KEY`: Required for actions that call the API (`pipeline import`, `pipeline new`, `pipeline update`, `pipeline migrate`, `pipeline get`, `pipeline list`, `pipeline delete`, `pipeline run`, `pipeline build`).
23
+ - `ORCHESTRA_API_KEY`: Credential for actions that call the API (`pipeline import`, `pipeline new`, `pipeline update`, `pipeline migrate`, `pipeline get`, `pipeline list`, `pipeline delete`, `pipeline run`, `pipeline build`). Not needed once you have run `orchestra login`: a login takes precedence, and the key is used only when you are not logged in or your login has expired.
24
24
  - `BASE_URL`: Optional. Override the default Orchestra host (`https://app.getorchestra.io`) for non‑production/testing.
25
25
 
26
26
  ## Command structure
@@ -29,6 +29,7 @@ Commands follow a `noun verb` shape. The current nouns are `pipeline` and `task`
29
29
 
30
30
  | Command | Description |
31
31
  | ------------------------------------ | ------------------------------------------------------------------------------------------- |
32
+ | `orchestra login` | Log in through your browser so later commands need no API key. |
32
33
  | `orchestra pipeline validate <file>` | Validate a pipeline YAML locally against the Orchestra API schema. |
33
34
  | `orchestra pipeline import` | Register a pipeline YAML (from a git repo) with Orchestra under an alias. |
34
35
  | `orchestra pipeline get` | Fetch one pipeline by path, alias, or pipeline ID. |
@@ -41,6 +42,8 @@ Commands follow a `noun verb` shape. The current nouns are `pipeline` and `task`
41
42
  | `orchestra pipeline build` | Validate local YAML, create or update a draft pipeline, and start that draft version. |
42
43
  | `orchestra task logs` | Fetch or follow logs for a single task run. |
43
44
 
45
+ Commands that read a local pipeline file (`pipeline validate`, `import`, `new`, `update`, `build`, and `run --path` with `--task`) accept `.yaml`, `.yml` and `.oml` files; any other extension is rejected. `.oml` files are read as YAML, so only OML written in YAML or JSON syntax is supported. OML block terminators (`.`) and embeds (`%sql ... %%`) fail with an `Invalid YAML` error.
46
+
44
47
  Use `orchestra --help`, `orchestra <noun> --help`, or `orchestra <noun> <verb> --help` for built-in help.
45
48
 
46
49
  ### Legacy command names
@@ -63,7 +66,7 @@ New code and documentation should prefer the noun/verb form.
63
66
 
64
67
  ## pipeline validate
65
68
 
66
- Validate a YAML file against the Orchestra API schema.
69
+ Validate a pipeline file against the Orchestra API schema.
67
70
 
68
71
  ```bash
69
72
  orchestra pipeline validate path/to/pipeline.yaml
@@ -73,7 +76,7 @@ orchestra-cli pipeline validate path/to/pipeline.yaml
73
76
 
74
77
  Options
75
78
 
76
- - `file` (positional): Path to the YAML file to validate.
79
+ - `file` (positional): Path to the `.yaml`, `.yml` or `.oml` file to validate.
77
80
 
78
81
  Behavior
79
82
 
@@ -96,6 +99,18 @@ pipeline:
96
99
 
97
100
  ---
98
101
 
102
+ ## login
103
+
104
+ Log in through your browser instead of pasting an API key. The token is cached in `~/.orchestra/credentials.json` (readable only by you) and refreshed automatically, so later commands need no `ORCHESTRA_API_KEY`.
105
+
106
+ ```bash
107
+ orchestra login
108
+ ```
109
+
110
+ Logins are per host: a login made with `BASE_URL` pointing at another environment is only used against that environment. CI and scripts, where nobody is logged in, keep using `ORCHESTRA_API_KEY`.
111
+
112
+ ---
113
+
99
114
  ## pipeline import
100
115
 
101
116
  Create (import) a pipeline in Orchestra by referencing a YAML file inside a git repository. The command infers your repository host/provider, default branch, and YAML path relative to the repo root.
@@ -3,7 +3,7 @@ from pathlib import Path
3
3
  import httpx
4
4
  import typer
5
5
 
6
- from ..utils.api import auth_headers, fail_with_response, request_or_exit, require_api_key
6
+ from ..utils.api import auth_headers, fail_with_response, request_or_exit, require_credential
7
7
  from ..utils.constants import get_create_pipeline_url, get_update_pipeline_url
8
8
  from ..utils.git import confirm_git_warnings_or_exit, prepare_git_backed_run_target
9
9
  from ..utils.pipeline_lookup import lookup_existing_pipeline
@@ -68,7 +68,6 @@ def _create_draft_pipeline(
68
68
  lookup_selector: PipelineSelector,
69
69
  pipeline_data: dict[str, object],
70
70
  force: bool,
71
- api_key: str,
72
71
  ) -> tuple[PipelineSelector, int]:
73
72
  create_selector = _build_create_selector(path, lookup_selector, force)
74
73
  payload = build_upsert_payload(pipeline_data, publish=False, selector=create_selector)
@@ -79,7 +78,7 @@ def _create_draft_pipeline(
79
78
  get_create_pipeline_url(),
80
79
  json=payload,
81
80
  timeout=30,
82
- headers=auth_headers(api_key),
81
+ headers=auth_headers(),
83
82
  )
84
83
 
85
84
  if create_response.status_code == 201:
@@ -98,7 +97,6 @@ def _create_draft_pipeline(
98
97
  def _update_draft_pipeline(
99
98
  existing_pipeline: dict[str, object],
100
99
  pipeline_data: dict[str, object],
101
- api_key: str,
102
100
  ) -> tuple[PipelineSelector, int]:
103
101
  update_selector = build_update_selector(existing_pipeline, "Build")
104
102
  payload = build_upsert_payload(pipeline_data, publish=False, selector=update_selector)
@@ -109,7 +107,7 @@ def _update_draft_pipeline(
109
107
  get_update_pipeline_url(),
110
108
  json=payload,
111
109
  timeout=30,
112
- headers=auth_headers(api_key),
110
+ headers=auth_headers(),
113
111
  )
114
112
 
115
113
  if update_response.status_code == 200:
@@ -145,7 +143,7 @@ def build_pipeline(
145
143
  """
146
144
  Validate local YAML, create or update a draft pipeline, and start the draft version.
147
145
  """
148
- api_key = require_api_key()
146
+ require_credential()
149
147
  if path is None:
150
148
  typer.echo(
151
149
  red("A pipeline YAML file path is required (use -p or --path with your YAML file)"),
@@ -155,7 +153,7 @@ def build_pipeline(
155
153
 
156
154
  confirm_git_warnings_or_exit(force, path)
157
155
  pipeline_data = load_validated_pipeline_data(path)
158
- existing_pipeline = lookup_existing_pipeline(lookup_selector, api_key, "Build", allow_404=True)
156
+ existing_pipeline = lookup_existing_pipeline(lookup_selector, "Build", allow_404=True)
159
157
 
160
158
  if existing_pipeline is None:
161
159
  run_selector, version_number = _create_draft_pipeline(
@@ -163,11 +161,9 @@ def build_pipeline(
163
161
  lookup_selector=lookup_selector,
164
162
  pipeline_data=pipeline_data,
165
163
  force=force,
166
- api_key=api_key,
167
164
  )
168
165
  start_pipeline_run(
169
166
  selector=run_selector,
170
- api_key=api_key,
171
167
  payload=build_run_payload(
172
168
  branch=branch,
173
169
  commit=commit,
@@ -183,12 +179,10 @@ def build_pipeline(
183
179
  run_selector, version_number = _update_draft_pipeline(
184
180
  existing_pipeline=existing_pipeline,
185
181
  pipeline_data=pipeline_data,
186
- api_key=api_key,
187
182
  )
188
183
 
189
184
  start_pipeline_run(
190
185
  selector=run_selector,
191
- api_key=api_key,
192
186
  payload=build_run_payload(
193
187
  branch=branch,
194
188
  commit=commit,
@@ -222,7 +216,6 @@ def build_pipeline(
222
216
 
223
217
  start_pipeline_run(
224
218
  selector=run_selector,
225
- api_key=api_key,
226
219
  payload=build_run_payload(
227
220
  branch=git_branch,
228
221
  commit=git_commit,
@@ -6,6 +6,7 @@ from .delete_pipeline import delete_pipeline
6
6
  from .fetch_pipelines import fetch_pipelines
7
7
  from .get_pipeline import get_pipeline
8
8
  from .import_pipeline import import_pipeline
9
+ from .login import login
9
10
  from .migrate_pipeline import migrate_pipeline
10
11
  from .run_pipeline import run_pipeline
11
12
  from .task_logs import task_logs
@@ -18,6 +19,7 @@ pipeline_app = typer.Typer(help="Manage Orchestra pipelines (validate, import, r
18
19
  app.add_typer(pipeline_app, name="pipeline")
19
20
  task_app = typer.Typer(help="Inspect Orchestra task runs.")
20
21
  app.add_typer(task_app, name="task")
22
+ app.command(name="login")(login)
21
23
 
22
24
  pipeline_app.command(name="validate")(validate)
23
25
  pipeline_app.command(name="import")(import_pipeline)
@@ -7,7 +7,7 @@ from ..utils.api import (
7
7
  auth_headers,
8
8
  fail_with_response,
9
9
  request_or_exit,
10
- require_api_key,
10
+ require_credential,
11
11
  )
12
12
  from ..utils.constants import get_create_pipeline_url
13
13
  from ..utils.pipeline_selector import (
@@ -36,7 +36,7 @@ def create_pipeline(
36
36
  """
37
37
  Create an Orchestra-backed pipeline from a local YAML file.
38
38
  """
39
- api_key = require_api_key()
39
+ require_credential()
40
40
  if path is None:
41
41
  typer.echo(red("Provide --path to create a pipeline from YAML"))
42
42
  raise typer.Exit(code=1)
@@ -54,7 +54,7 @@ def create_pipeline(
54
54
  get_create_pipeline_url(),
55
55
  json=payload,
56
56
  timeout=30,
57
- headers=auth_headers(api_key),
57
+ headers=auth_headers(),
58
58
  )
59
59
 
60
60
  if response.status_code == 201:
@@ -7,7 +7,7 @@ from ..utils.api import (
7
7
  auth_headers,
8
8
  fail_with_response,
9
9
  request_or_exit,
10
- require_api_key,
10
+ require_credential,
11
11
  )
12
12
  from ..utils.constants import get_delete_pipeline_url
13
13
  from ..utils.pipeline_selector import (
@@ -27,7 +27,7 @@ def delete_pipeline(
27
27
  """
28
28
  Delete a pipeline by selector.
29
29
  """
30
- api_key = require_api_key()
30
+ require_credential()
31
31
  selector = resolve_pipeline_selector(alias, pipeline_id, path)
32
32
 
33
33
  if not typer.confirm(f"Delete pipeline ({selector.display()})?"):
@@ -39,7 +39,7 @@ def delete_pipeline(
39
39
  get_delete_pipeline_url(),
40
40
  params=selector.to_payload(),
41
41
  timeout=30,
42
- headers=auth_headers(api_key),
42
+ headers=auth_headers(),
43
43
  )
44
44
 
45
45
  if response.status_code == 204:
@@ -7,7 +7,7 @@ from ..utils.api import (
7
7
  auth_headers,
8
8
  fail_with_response,
9
9
  request_or_exit,
10
- require_api_key,
10
+ require_credential,
11
11
  )
12
12
  from ..utils.constants import get_api_url
13
13
  from ..utils.styling import indent_message, red, yellow
@@ -19,13 +19,13 @@ def fetch_pipelines():
19
19
 
20
20
  The API always includes each pipeline's latest run metadata.
21
21
  """
22
- api_key = require_api_key()
22
+ require_credential()
23
23
 
24
24
  response = request_or_exit(
25
25
  httpx.get,
26
26
  get_api_url("pipelines"),
27
27
  timeout=30,
28
- headers=auth_headers(api_key),
28
+ headers=auth_headers(),
29
29
  )
30
30
 
31
31
  if response.status_code == 200:
@@ -10,7 +10,7 @@ from ..utils.api import (
10
10
  auth_headers,
11
11
  fail_with_response,
12
12
  request_or_exit,
13
- require_api_key,
13
+ require_credential,
14
14
  )
15
15
  from ..utils.constants import get_pipeline_url
16
16
  from ..utils.pipeline_selector import (
@@ -50,7 +50,7 @@ def get_pipeline(
50
50
  """
51
51
  Fetch one pipeline using the shared selector model.
52
52
  """
53
- api_key = require_api_key()
53
+ require_credential()
54
54
  selector = resolve_pipeline_selector(alias, pipeline_id, path)
55
55
 
56
56
  response = request_or_exit(
@@ -58,7 +58,7 @@ def get_pipeline(
58
58
  get_pipeline_url(),
59
59
  params=selector.to_payload(),
60
60
  timeout=30,
61
- headers=auth_headers(api_key),
61
+ headers=auth_headers(),
62
62
  )
63
63
 
64
64
  if response.status_code == 200:
@@ -8,7 +8,7 @@ from ..utils.api import (
8
8
  auth_headers,
9
9
  fail_with_response,
10
10
  request_or_exit,
11
- require_api_key,
11
+ require_credential,
12
12
  )
13
13
  from ..utils.constants import get_api_url
14
14
  from ..utils.git import (
@@ -43,7 +43,7 @@ def import_pipeline(
43
43
  """
44
44
  Create a pipeline in Orchestra by referencing a YAML file in your git repository.
45
45
  """
46
- api_key = require_api_key()
46
+ require_credential()
47
47
  if path is None:
48
48
  typer.echo(red("Provide --path to import a pipeline from git"))
49
49
  raise typer.Exit(code=1)
@@ -102,7 +102,7 @@ def import_pipeline(
102
102
  get_api_url("pipelines/import"),
103
103
  json=payload,
104
104
  timeout=30,
105
- headers=auth_headers(api_key),
105
+ headers=auth_headers(),
106
106
  )
107
107
 
108
108
  if response.status_code == 201:
@@ -0,0 +1,162 @@
1
+ import base64
2
+ import hashlib
3
+ import secrets
4
+ import time
5
+ import webbrowser
6
+ from http.server import BaseHTTPRequestHandler, HTTPServer
7
+ from importlib.resources import files
8
+ from urllib.parse import parse_qs, urlencode, urlparse
9
+
10
+ import httpx
11
+ import typer
12
+
13
+ from ..utils.api import fail_with_response, request_or_exit, token_response_to_credentials
14
+ from ..utils.constants import get_base_url
15
+ from ..utils.credentials import save_credentials
16
+ from ..utils.styling import green, red, yellow
17
+
18
+ SCOPES = "orchestra:read orchestra:write offline_access"
19
+ CALLBACK_PATH = "/callback"
20
+ LOGIN_TIMEOUT_SECONDS = 300
21
+
22
+
23
+ class _CallbackHandler(BaseHTTPRequestHandler):
24
+ params: dict[str, str] | None = None
25
+ # Bounds reads on connections a browser opens speculatively and never uses, which
26
+ # would otherwise block the single-threaded server past the login deadline.
27
+ timeout = 5
28
+
29
+ def do_GET(self): # noqa: N802
30
+ url = urlparse(self.path)
31
+ if url.path != CALLBACK_PATH:
32
+ self.send_response(404)
33
+ self.end_headers()
34
+ return
35
+ _CallbackHandler.params = {k: v[0] for k, v in parse_qs(url.query).items()}
36
+ self.send_response(200)
37
+ self.send_header("Content-Type", "text/html; charset=utf-8")
38
+ self.end_headers()
39
+ self.wfile.write(files(__package__).joinpath("login_page.html").read_bytes())
40
+
41
+ def log_message(self, format, *args): # noqa: ARG002
42
+ pass
43
+
44
+
45
+ def _pkce_pair() -> tuple[str, str]:
46
+ verifier = secrets.token_urlsafe(64)
47
+ digest = hashlib.sha256(verifier.encode()).digest()
48
+ challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
49
+ return verifier, challenge
50
+
51
+
52
+ def _wait_for_callback(server: HTTPServer, state: str) -> dict[str, str]:
53
+ """Wait for the redirect carrying this login's ``state``.
54
+
55
+ Any other request to the callback (a prefetch, a stale tab, another local
56
+ process) is ignored rather than ending the login.
57
+ """
58
+ deadline = time.monotonic() + LOGIN_TIMEOUT_SECONDS
59
+ server.timeout = 1
60
+ while True:
61
+ _CallbackHandler.params = None
62
+ if time.monotonic() > deadline:
63
+ typer.echo(red("Timed out waiting for the browser to complete login"))
64
+ raise typer.Exit(code=1)
65
+ server.handle_request()
66
+ params = _CallbackHandler.params
67
+ if params is not None and params.get("state") == state:
68
+ return params
69
+
70
+
71
+ def login():
72
+ """
73
+ Log in to Orchestra in your browser and cache the token for later commands.
74
+ """
75
+ base_url = get_base_url()
76
+ response = request_or_exit(
77
+ httpx.get,
78
+ f"{base_url}/.well-known/oauth-authorization-server",
79
+ timeout=30,
80
+ )
81
+ if response.status_code != 200:
82
+ raise fail_with_response("Discovery", response)
83
+ # The web app answers unknown paths with a 200 HTML page, so a host without an
84
+ # authorization server behind it still reaches this point.
85
+ try:
86
+ metadata = response.json()
87
+ except ValueError:
88
+ metadata = None
89
+ required = ("authorization_endpoint", "token_endpoint", "registration_endpoint")
90
+ if not isinstance(metadata, dict) or not all(k in metadata for k in required):
91
+ typer.echo(red(f"{base_url} does not support `orchestra login`"))
92
+ raise typer.Exit(code=1)
93
+
94
+ with HTTPServer(("127.0.0.1", 0), _CallbackHandler) as server:
95
+ redirect_uri = f"http://127.0.0.1:{server.server_port}{CALLBACK_PATH}"
96
+
97
+ response = request_or_exit(
98
+ httpx.post,
99
+ metadata["registration_endpoint"],
100
+ json={
101
+ "client_name": "Orchestra CLI",
102
+ "redirect_uris": [redirect_uri],
103
+ "grant_types": ["authorization_code", "refresh_token"],
104
+ "response_types": ["code"],
105
+ "token_endpoint_auth_method": "none",
106
+ },
107
+ timeout=30,
108
+ )
109
+ if response.status_code not in (200, 201):
110
+ raise fail_with_response("Client registration", response)
111
+ client_id = response.json()["client_id"]
112
+
113
+ verifier, challenge = _pkce_pair()
114
+ state = secrets.token_urlsafe(32)
115
+ authorize_url = f"{metadata['authorization_endpoint']}?" + urlencode(
116
+ {
117
+ "response_type": "code",
118
+ "client_id": client_id,
119
+ "redirect_uri": redirect_uri,
120
+ "scope": SCOPES,
121
+ "state": state,
122
+ "code_challenge": challenge,
123
+ "code_challenge_method": "S256",
124
+ },
125
+ )
126
+
127
+ typer.echo(yellow("Opening your browser to log in. If it does not open, visit:"))
128
+ typer.echo(authorize_url)
129
+ webbrowser.open(authorize_url)
130
+ params = _wait_for_callback(server, state)
131
+
132
+ if "error" in params:
133
+ detail = params.get("error_description", params["error"])
134
+ typer.echo(red(f"Login failed: {detail}"))
135
+ raise typer.Exit(code=1)
136
+
137
+ response = request_or_exit(
138
+ httpx.post,
139
+ metadata["token_endpoint"],
140
+ data={
141
+ "grant_type": "authorization_code",
142
+ "code": params.get("code", ""),
143
+ "redirect_uri": redirect_uri,
144
+ "client_id": client_id,
145
+ "code_verifier": verifier,
146
+ },
147
+ timeout=30,
148
+ )
149
+ if response.status_code != 200:
150
+ raise fail_with_response("Token exchange", response)
151
+ token = response.json()
152
+ if "refresh_token" not in token:
153
+ typer.echo(red("Login failed: no refresh token was granted, so the login cannot be kept"))
154
+ raise typer.Exit(code=1)
155
+
156
+ save_credentials(
157
+ token_response_to_credentials(
158
+ token,
159
+ {"client_id": client_id, "token_endpoint": metadata["token_endpoint"]},
160
+ ),
161
+ )
162
+ typer.echo(green(f"✅ Logged in to {base_url}"))
@@ -0,0 +1,42 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Orchestra CLI login</title>
7
+ <style>
8
+ html, body { height: 100%; margin: 0; }
9
+ body {
10
+ display: flex;
11
+ align-items: center;
12
+ justify-content: center;
13
+ padding: 0 16px;
14
+ background: linear-gradient(to bottom, #3754BF, #253B8E 45%, #0D1431);
15
+ color: #FFFFFF;
16
+ font-family: "DM Sans", system-ui, -apple-system, "Segoe UI", sans-serif;
17
+ text-align: center;
18
+ }
19
+ .logo { height: 34px; width: auto; margin-bottom: 40px; }
20
+ h1 { margin: 0 0 12px; font-size: 28px; font-weight: 500; }
21
+ p { margin: 0; font-size: 16px; color: #BAC7F4; }
22
+ </style>
23
+ </head>
24
+ <body>
25
+ <main>
26
+ <svg class="logo" role="img" aria-label="Orchestra" width="210" height="34" viewBox="0 0 210 34" fill="none" xmlns="http://www.w3.org/2000/svg">
27
+ <path d="M46.4619 16.8629C46.4619 8.8581 51.4514 3.26221 59.249 3.26221C67.0467 3.26221 72.073 8.8581 72.073 16.8629C72.073 24.8677 67.0467 30.4636 59.249 30.4636C51.4514 30.4636 46.4619 24.8677 46.4619 16.8629ZM66.8626 16.8629C66.8626 11.4525 64.0903 7.59826 59.249 7.59826C54.4077 7.59826 51.6732 11.4525 51.6732 16.8629C51.6732 22.2733 54.4445 26.1276 59.249 26.1276C64.0535 26.1276 66.8626 22.2733 66.8626 16.8629Z" fill="white"/>
28
+ <path d="M86.1213 11.1182V15.3426H84.3108C81.1695 15.3426 79.4695 16.5655 79.4327 20.4197V30.0185H74.7764V11.1182H79.3223V14.6392C80.0245 12.749 81.7981 11.1182 84.829 11.1182H86.1222H86.1213Z" fill="white"/>
29
+ <path d="M96.4644 30.4638C90.7727 30.4638 86.8923 26.2764 86.8923 20.5688C86.8923 14.8613 90.7359 10.7485 96.4276 10.7485C101.047 10.7485 104.337 13.2682 105.186 17.6043H100.493C100.013 15.4547 98.6826 14.1579 96.5013 14.1579C93.5081 14.1579 91.8449 16.7893 91.8449 20.5688C91.8449 24.3483 93.5081 27.0544 96.5013 27.0544C98.8667 27.0544 100.013 25.646 100.493 23.5712H105.186C104.337 28.018 101.047 30.4638 96.4644 30.4638Z" fill="white"/>
30
+ <path d="M112.572 3.82422V14.1938C113.57 12.3775 115.418 10.7475 118.633 10.7475C122.698 10.7475 124.842 13.2303 124.842 17.0845V30.0179H120.149V18.1219C120.149 15.8247 119.224 14.4163 116.712 14.4163C114.199 14.4163 112.573 16.3065 112.573 19.271V30.0179H107.916V3.82422H112.573H112.572Z" fill="white"/>
31
+ <path d="M137.074 10.7485C142.988 10.7485 146.646 15.3809 146.425 21.6071H132.308C132.493 25.0535 134.266 27.1292 137.186 27.1292C139.514 27.1292 140.807 25.7577 141.398 24.0161H146.055C145.02 27.6848 141.952 30.4647 137.148 30.4647C131.383 30.4647 127.502 26.2773 127.502 20.5697C127.502 14.8622 131.309 10.7494 137.074 10.7494V10.7485ZM137.074 14.0463C134.487 14.0463 132.862 15.8626 132.455 18.6786H141.694C141.25 15.8987 139.662 14.0463 137.074 14.0463Z" fill="white"/>
32
+ <path d="M147.76 23.9041H152.528C152.787 26.2761 154.523 27.2766 156.852 27.2766C159.181 27.2766 160.178 26.0906 160.178 24.6822C160.178 22.9775 158.922 22.6074 155.817 21.8653C151.605 20.8648 148.389 19.8274 148.389 16.1956C148.389 12.9348 151.567 10.7104 156.408 10.7104C161.25 10.7104 164.206 13.1194 164.686 16.418H159.993C159.772 14.7872 158.478 13.8236 156.372 13.8236C154.265 13.8236 153.12 14.7502 153.12 15.8615C153.12 17.3807 154.672 17.7148 157.444 18.3812C161.657 19.3817 165.021 20.3821 165.021 24.385C165.021 28.3879 161.731 30.4627 156.853 30.4627C151.494 30.4627 148.168 28.0538 147.761 23.9032L147.76 23.9041Z" fill="white"/>
33
+ <path d="M173.555 14.3419V24.5702C173.555 26.201 173.962 26.6459 175.477 26.6459H177.694V30.0184H173.962C170.118 30.0184 168.898 28.3875 168.898 24.9043V14.3419H166.089V11.118H168.898V5.80762H173.555V11.118H177.694V14.3419H173.555Z" fill="white"/>
34
+ <path d="M191.738 11.1182V15.3426H189.928C186.786 15.3426 185.086 16.5655 185.049 20.4197V30.0185H180.393V11.1182H184.939V14.6392C185.641 12.749 187.415 11.1182 190.446 11.1182H191.739H191.738Z" fill="white"/>
35
+ <path d="M192.419 24.6824C192.419 20.902 195.264 19.7169 199.699 18.5678C202.655 17.7898 204.614 17.4557 204.614 16.0112C204.614 14.7883 203.394 13.9355 201.288 13.9355C199.181 13.9355 197.703 14.8991 197.555 16.6776H192.936C193.38 13.3421 196.262 10.7485 201.436 10.7485C206.609 10.7485 209.418 12.8612 209.418 16.8262V30.019H204.984V26.7951C204.282 28.7591 202.471 30.4638 198.923 30.4638C195.375 30.4638 192.419 28.3512 192.419 24.6824ZM204.8 22.1249V18.8263C204.393 19.8268 203.247 20.3455 201.326 20.9389C198.814 21.6801 197.224 22.4581 197.224 24.4222C197.224 26.1638 198.592 27.2012 200.476 27.2012C203.21 27.2012 204.8 24.904 204.8 22.124V22.1249Z" fill="white"/>
36
+ <path d="M25.7719 10.3911L30.0232 6.143C32.1819 8.74145 33.5794 11.9935 33.8765 15.5586H27.8726C27.6236 13.6369 26.8803 11.8705 25.7719 10.3911ZM10.2383 8.23585C12.0953 6.81357 14.4182 5.96822 16.9383 5.96822C19.4583 5.96822 21.7812 6.81357 23.6383 8.23585L27.8851 3.99219C24.9277 1.50164 21.1084 0 16.9383 0C12.7682 0 8.94879 1.50164 5.99144 3.99219L10.2383 8.23585ZM6.00393 15.5586C6.25291 13.6369 6.99626 11.8705 8.1046 10.3911L3.8533 6.143C1.69463 8.74234 0.297163 11.9944 0 15.5586H6.00393ZM27.8476 18.5904C27.0695 23.9148 22.4826 28.0033 16.9383 28.0033C11.3939 28.0033 6.80707 23.9148 6.02892 18.5904H0.0160628C0.825451 27.2186 8.0921 33.9706 16.9383 33.9706C25.7844 33.9706 33.0511 27.2177 33.8605 18.5904H27.8476ZM16.9383 10.5641C13.3893 10.5641 10.5113 13.439 10.5113 16.9862C10.5113 20.5334 13.3884 23.4083 16.9383 23.4083C20.4882 23.4083 23.3652 20.5334 23.3652 16.9862C23.3652 13.439 20.4882 10.5641 16.9383 10.5641Z" fill="white"/>
37
+ </svg>
38
+ <h1>Return to your terminal</h1>
39
+ <p>to finish logging in to Orchestra. You can close this tab.</p>
40
+ </main>
41
+ </body>
42
+ </html>
@@ -5,7 +5,7 @@ from typing import Any
5
5
  import httpx
6
6
  import typer
7
7
 
8
- from ..utils.api import auth_headers, fail_with_response, request_or_exit, require_api_key
8
+ from ..utils.api import auth_headers, fail_with_response, request_or_exit, require_credential
9
9
  from ..utils.constants import get_api_url
10
10
  from ..utils.git import (
11
11
  GitAction,
@@ -121,7 +121,7 @@ def _choose_migration_version(existing_pipeline: dict[str, object], force: bool)
121
121
  raise typer.Exit(code=1)
122
122
 
123
123
 
124
- def _download_pipeline_yaml(selector: PipelineSelector, version: int | None, api_key: str) -> str:
124
+ def _download_pipeline_yaml(selector: PipelineSelector, version: int | None) -> str:
125
125
  params = selector.to_payload()
126
126
  if version is not None:
127
127
  params["version"] = str(version)
@@ -131,7 +131,7 @@ def _download_pipeline_yaml(selector: PipelineSelector, version: int | None, api
131
131
  get_api_url("pipeline/data"),
132
132
  params=params,
133
133
  timeout=30,
134
- headers=auth_headers(api_key),
134
+ headers=auth_headers(),
135
135
  )
136
136
  if response.status_code != 200:
137
137
  raise fail_with_response("Migrate", response)
@@ -344,7 +344,7 @@ def migrate_pipeline(
344
344
  """
345
345
  Migrate an Orchestra-backed pipeline to git-backed storage.
346
346
  """
347
- api_key = require_api_key()
347
+ require_credential()
348
348
  selector = _resolve_migrate_selector(alias, pipeline_id)
349
349
 
350
350
  repo_root = require_repo_root(path, GitAction.MIGRATE)
@@ -370,7 +370,7 @@ def migrate_pipeline(
370
370
  typer.echo(red("Could not detect current branch from git"))
371
371
  raise typer.Exit(code=1)
372
372
 
373
- existing_pipeline = lookup_existing_pipeline(selector, api_key, "Migrate")
373
+ existing_pipeline = lookup_existing_pipeline(selector, "Migrate")
374
374
  if existing_pipeline is None:
375
375
  typer.echo(red("❌ Migrate failed: pipeline lookup returned no pipeline"))
376
376
  raise typer.Exit(code=1)
@@ -382,7 +382,7 @@ def migrate_pipeline(
382
382
  raise typer.Exit(code=1)
383
383
 
384
384
  target_version = _choose_migration_version(existing_pipeline, force)
385
- downloaded_yaml = _download_pipeline_yaml(selector, target_version, api_key)
385
+ downloaded_yaml = _download_pipeline_yaml(selector, target_version)
386
386
  target_path, selected_yaml = _resolve_target_path_and_yaml(path, downloaded_yaml, force)
387
387
  relative_path = ensure_repo_relative_path(target_path, repo_root, GitAction.MIGRATE)
388
388
  _write_yaml(target_path, selected_yaml)
@@ -411,7 +411,7 @@ def migrate_pipeline(
411
411
  params=selector.to_payload(),
412
412
  json=payload,
413
413
  timeout=30,
414
- headers=auth_headers(api_key),
414
+ headers=auth_headers(),
415
415
  )
416
416
  if not (200 <= response.status_code < 300):
417
417
  raise fail_with_response("Migrate", response)