elan-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
elan_cli/main.py ADDED
@@ -0,0 +1,664 @@
1
+ """`elan` — the command line a developer deploys an agent with.
2
+
3
+ WHAT IS HERE IS WHAT WORKS. The plan lists a longer command set, and the ones
4
+ that need a service route nobody has built are ABSENT rather than stubbed. A
5
+ subcommand that parses its arguments, prints "not implemented" and exits 0 is
6
+ worse than no subcommand: it is discoverable in `--help`, it looks supported,
7
+ and somebody writes it into a script.
8
+
9
+ Still missing, and each for a stated reason rather than an oversight:
10
+
11
+ `budget` the ledger's per-invocation gate is vestigial under BYOK
12
+ (ADR-0063); there is nothing honest to report yet.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ from elan_cli import adk as adk_cmd
22
+ from elan_cli import (
23
+ auth,
24
+ deploy,
25
+ env,
26
+ files,
27
+ rollback,
28
+ scaffold,
29
+ secret,
30
+ tenant,
31
+ validate,
32
+ wallet,
33
+ )
34
+ from elan_cli import destroy as destroy_cmd
35
+ from elan_cli import doctor as doctor_cmd
36
+ from elan_cli import invoke as invoke_cmd
37
+ from elan_cli import logs as logs_cmd
38
+ from elan_cli import projects as projects_cmd
39
+ from elan_cli.auth import AuthError
40
+ from elan_cli.client import Api, ApiError
41
+ from elan_cli.config import Config, ConfigError
42
+ from elan_cli.files import FilesError # noqa: F401 — re-exported for tests
43
+ from elan_cli.project import ProjectError, find
44
+ from elan_cli.secret import SecretError
45
+ from elan_cli.wallet import WalletError
46
+
47
+
48
+ def build_parser() -> argparse.ArgumentParser:
49
+ parser = argparse.ArgumentParser(
50
+ prog="elan", description="Deploy an ADK agent to the platform.")
51
+ sub = parser.add_subparsers(dest="command", metavar="<command>")
52
+
53
+ login = sub.add_parser("login", help="sign in with a browser, on any machine")
54
+ login.add_argument("--api", default="",
55
+ help="the platform's URL. Needed once; remembered "
56
+ "afterwards. Everything else about signing in is "
57
+ "asked of the platform rather than configured.")
58
+ sub.add_parser("logout", help="forget the stored refresh token")
59
+ sub.add_parser("whoami", help="who the stored credential says you are")
60
+
61
+ init = sub.add_parser("init", help="create elan.yaml and its directories")
62
+ init.add_argument("--tenant", required=True)
63
+ init.add_argument("--project", required=True)
64
+ init.add_argument("--repo", default="https://github.com/ORG/REPO")
65
+ init.add_argument("--directory", type=Path, default=None)
66
+ init.add_argument("--agent", default="hello-agent",
67
+ help="the first agent's slug; a project with none does "
68
+ "not validate, so one is always created")
69
+ # NO `--runtime`. It named a checkout of the platform repository, because
70
+ # the agentkit packages were published to no index; they are on PyPI now,
71
+ # so `init` builds the .venv unconditionally and has nothing to ask for
72
+ # (ADR-0220).
73
+
74
+ new = sub.add_parser("new", help="scaffold a target and declare it")
75
+ new.add_argument("kind", choices=sorted(scaffold.KINDS))
76
+ new.add_argument("slug")
77
+
78
+ # `parse_known_args` IS NOT USED and REMAINDER is, because every option
79
+ # after `adk` belongs to ADK. argparse would otherwise claim `--port` and
80
+ # refuse it as unknown, which reads as elan not supporting something ADK
81
+ # documents.
82
+ passthrough = sub.add_parser(
83
+ "adk", help="run ADK's own tools against this repo (web, run, eval)")
84
+ passthrough.add_argument("args", nargs=argparse.REMAINDER,
85
+ help="Passed to ADK unchanged.")
86
+
87
+ # `env` TAKES NOTHING, for the same reason `init` lost its flag: the
88
+ # runtime resolves from the index by name.
89
+ sub.add_parser(
90
+ "env", help="create the local environment `elan validate` needs")
91
+
92
+ check = sub.add_parser("validate",
93
+ help="check both files against each other")
94
+ check.add_argument("--python", default=None, metavar="PATH",
95
+ help="Interpreter to read your agents with. Defaults "
96
+ "to an activated virtualenv, then a .venv beside "
97
+ "elan.yaml. It needs the platform runtime "
98
+ "installed, which the CLI's own environment "
99
+ "deliberately does not have.")
100
+
101
+ back = sub.add_parser("rollback",
102
+ help="put this project back to a previous deploy")
103
+ back.add_argument("to_deployment_id",
104
+ help="the deployment to restore, from `elan status` or "
105
+ "the console. Not a digest and not a ref: a digest "
106
+ "names one image and a project has several, and a "
107
+ "ref names a commit that may have been "
108
+ "force-pushed away.")
109
+ back.add_argument("--yes", action="store_true",
110
+ help="skip the confirmation")
111
+ back.add_argument("--no-wait", action="store_true")
112
+
113
+ gone = sub.add_parser("destroy",
114
+ help="remove an agent or a project and its resources")
115
+ gone.add_argument("agent", nargs="?", default="",
116
+ help="the agent to remove; omit with --project")
117
+ gone.add_argument("--project", action="store_true",
118
+ help="remove the whole project, every agent in it")
119
+ gone.add_argument("--yes", action="store_true", help="skip the confirmation")
120
+
121
+ sub.add_parser("doctor",
122
+ help="check the platform's record against what exists")
123
+
124
+ send = sub.add_parser("invoke", help="send an agent a message")
125
+ send.add_argument("target", help="the agent's slug")
126
+ send.add_argument("--text", required=True)
127
+ send.add_argument("--session", default="",
128
+ help="continue a conversation instead of starting one")
129
+ send.add_argument("--timeout", type=float,
130
+ default=invoke_cmd.DEFAULT_TIMEOUT,
131
+ help="seconds to wait for a reply (default 300; a "
132
+ "sleeping agent has to be scheduled and started "
133
+ "before it reads its queue)")
134
+
135
+ log = sub.add_parser("logs", help="a build's output, or a workload's")
136
+ # OPTIONAL, BECAUSE `--workload` REPLACES IT. argparse cannot express "one of
137
+ # these two" in a way that reads well in `--help`, so the check is in the
138
+ # dispatch and the error names both forms.
139
+ log.add_argument("deployment_id", nargs="?", default="",
140
+ help="a deployment id, for a BUILD log")
141
+ log.add_argument("--workload", default="",
142
+ help="a workload name, for what it is saying NOW")
143
+ log.add_argument("--project", default="",
144
+ help="which project the workload is in (required with "
145
+ "--workload: a workload name is unique within a "
146
+ "project, not within a tenant)")
147
+ log.add_argument("--since", type=int, default=60, metavar="MINUTES",
148
+ help="how far back to read a workload's log (default 60)")
149
+ log.add_argument("--probes", action="store_true",
150
+ help="include health-check requests, which are most of an "
151
+ "idle pod's output and are hidden by default")
152
+ log.add_argument("--tail", nargs="?", type=int, const=logs_cmd.DEFAULT_TAIL,
153
+ default=0, help="only the last lines (default 50)")
154
+
155
+ projects = sub.add_parser("projects", help="this tenant's projects")
156
+ projects_sub = projects.add_subparsers(dest="projects_command")
157
+ projects_sub.add_parser("ls", help="list them")
158
+ made = projects_sub.add_parser("create", help="register a new one")
159
+ made.add_argument("slug")
160
+ made.add_argument("--name", default="")
161
+ made.add_argument("--repo", default="")
162
+
163
+ agents = sub.add_parser("agents", help="what each agent is running")
164
+ agents.add_argument("--project", default="",
165
+ help="defaults to every project of this tenant")
166
+
167
+ deploy_cmd = sub.add_parser("deploy", help="build and roll out this project")
168
+ deploy_cmd.add_argument("--ref", default="HEAD",
169
+ help="branch, tag or sha; resolved and written "
170
+ "into elan.yaml before submitting")
171
+ deploy_cmd.add_argument("--no-wait", action="store_true",
172
+ help="submit and return the id")
173
+
174
+ status = sub.add_parser("status", help="how a deployment is getting on")
175
+ status.add_argument("deployment_id")
176
+
177
+ # PLATFORM STAFF ONLY, enforced by the service rather than here. A CLI that
178
+ # refused locally would be a second rule to keep in step with the one that
179
+ # actually holds the credential.
180
+ tenant_cmd = sub.add_parser("tenant", help="create and list tenants (platform staff)")
181
+ tenant_sub = tenant_cmd.add_subparsers(dest="tenant_command")
182
+ tenant_create = tenant_sub.add_parser("create", help="create a customer")
183
+ tenant_create.add_argument("slug")
184
+ tenant_create.add_argument("--name", default="",
185
+ help="what a human sees; defaults to the slug")
186
+ tenant_sub.add_parser("ls", help="every tenant")
187
+
188
+ secret_cmd = sub.add_parser("secret", help="your own credentials")
189
+ secret_sub = secret_cmd.add_subparsers(dest="secret_command")
190
+ secret_set = secret_sub.add_parser("set", help="set or rotate a value")
191
+ secret_set.add_argument("kind", choices=secret.KINDS)
192
+ secret_set.add_argument("name")
193
+ # NOT `--value`. A secret on the command line is a secret in the shell
194
+ # history of everyone who runs it.
195
+ secret_set.add_argument("--from-file", default="-", metavar="PATH",
196
+ help="file holding the value, or - for stdin "
197
+ "(the default)")
198
+ secret_sub.add_parser("ls", help="which secrets are set (names only)")
199
+ secret_rm = secret_sub.add_parser("rm", help="remove a secret entirely")
200
+ secret_rm.add_argument("kind", choices=secret.KINDS)
201
+ secret_rm.add_argument("name")
202
+
203
+ # ── FILES AN AGENT READS AND WRITES ────────────────────────────────────
204
+ #
205
+ # THE SCOPE IS IN THE FILENAME AND NOT IN A FLAG, because that is how a
206
+ # tool addresses it: `project:policy.md` from here is the same name
207
+ # `tool_context.load_artifact` takes. A `--scope` flag would be a second
208
+ # way to say the same thing, and the two would disagree.
209
+ files_cmd = sub.add_parser(
210
+ "files", help="artifacts an agent reads and writes")
211
+ files_scope = argparse.ArgumentParser(add_help=False)
212
+ files_scope.add_argument(
213
+ "--project", default="",
214
+ help="defaults to the project of the elan.yaml you are standing in")
215
+ files_scope.add_argument(
216
+ "target", help="the agent or workflow slug")
217
+ files_scope.add_argument(
218
+ "--user", default="",
219
+ help="one of YOUR end users, for a file that is theirs")
220
+ files_sub = files_cmd.add_subparsers(dest="files_command")
221
+
222
+ files_ls = files_sub.add_parser(
223
+ "ls", parents=[files_scope], help="what this conversation can see")
224
+ files_ls.add_argument("--session", default="", help="the session id")
225
+
226
+ files_get = files_sub.add_parser(
227
+ "get", parents=[files_scope], help="download one file")
228
+ files_get.add_argument("filename", help="e.g. report.pdf or project:policy.md")
229
+ files_get.add_argument("--session", default="")
230
+ files_get.add_argument("--version", type=int, help="defaults to the newest")
231
+ files_get.add_argument("-o", "--out", default="", help="where to write it")
232
+
233
+ files_put = files_sub.add_parser(
234
+ "put", parents=[files_scope], help="upload one file")
235
+ files_put.add_argument("path", help="the local file")
236
+ files_put.add_argument(
237
+ "--name", default="",
238
+ help="the artifact name, which chooses the scope. Defaults to the "
239
+ "file's own name, i.e. this conversation's scope. Prefix with "
240
+ "`project:` or `agent:` for a shared document (admin only).")
241
+ files_put.add_argument("--session", default="")
242
+ files_put.add_argument("--content-type", default="application/octet-stream",
243
+ help="signed into the URL; a later read reports it")
244
+
245
+ files_rm = files_sub.add_parser(
246
+ "rm", parents=[files_scope], help="remove one file and every version")
247
+ files_rm.add_argument("filename")
248
+ files_rm.add_argument("--session", default="")
249
+
250
+ # ── YOUR OWN END USERS' CREDIT ──────────────────────────────────────────
251
+ #
252
+ # NO `--tenant` ANYWHERE BELOW. Every route derives it from the verified
253
+ # token, so a flag would name a value the service ignores — and a flag that
254
+ # does nothing is worse than no flag.
255
+ wallet_cmd = sub.add_parser(
256
+ "wallet", help="credit you hold for your own end users")
257
+ # ONE `--project` FOR EVERY SUBCOMMAND, declared on a shared parent rather
258
+ # than repeated eight times — repeated flags are how one of them comes to
259
+ # differ.
260
+ wallet_scope = argparse.ArgumentParser(add_help=False)
261
+ wallet_scope.add_argument(
262
+ "--project", default="",
263
+ help="defaults to the project of the elan.yaml you are standing in")
264
+ wallet_sub = wallet_cmd.add_subparsers(dest="wallet_command")
265
+
266
+ wallet_ls = wallet_sub.add_parser(
267
+ "ls", parents=[wallet_scope], help="your users and what they have")
268
+ wallet_ls.add_argument("--limit", type=int, default=50)
269
+
270
+ wallet_show = wallet_sub.add_parser(
271
+ "show", parents=[wallet_scope], help="one user's balance")
272
+ wallet_show.add_argument("user")
273
+
274
+ wallet_new = wallet_sub.add_parser(
275
+ "create", parents=[wallet_scope], help="make a wallet for one of your users")
276
+ wallet_new.add_argument("user")
277
+ wallet_new.add_argument("--label", default="", help="for you, reading a list")
278
+
279
+ # DOLLARS POSITIONALLY, `--micros` FOR A CALLER THAT ALREADY HAS THE INTEGER.
280
+ # A person types `5`; a script that read a balance back should not have to
281
+ # divide by a million to pass it on.
282
+ wallet_add = wallet_sub.add_parser(
283
+ "credit", parents=[wallet_scope], help="add credit, in dollars")
284
+ wallet_add.add_argument("user")
285
+ wallet_add.add_argument("amount", nargs="?", help="in dollars, e.g. 5 or 2.50")
286
+ wallet_add.add_argument("--micros", type=int, help="in micro-USD instead")
287
+ wallet_add.add_argument("--ref", default="",
288
+ help="why — it is what makes a double credit visible")
289
+
290
+ wallet_fix = wallet_sub.add_parser(
291
+ "adjust", parents=[wallet_scope], help="correct in either direction; negative claws back")
292
+ wallet_fix.add_argument("user")
293
+ wallet_fix.add_argument("amount", nargs="?", help="in dollars, e.g. -2.50")
294
+ wallet_fix.add_argument("--micros", type=int)
295
+ wallet_fix.add_argument("--ref", default="")
296
+
297
+ wallet_freeze = wallet_sub.add_parser(
298
+ "freeze", parents=[wallet_scope], help="stop this user spending, keeping their balance")
299
+ wallet_freeze.add_argument("user")
300
+ wallet_thaw = wallet_sub.add_parser(
301
+ "unfreeze", parents=[wallet_scope], help="let them spend again")
302
+ wallet_thaw.add_argument("user")
303
+
304
+ wallet_log = wallet_sub.add_parser(
305
+ "statement", parents=[wallet_scope], help="one user's movements, newest first")
306
+ wallet_log.add_argument("user")
307
+ wallet_log.add_argument("--limit", type=int, default=25)
308
+ return parser
309
+
310
+
311
+ def run(argv: list[str] | None = None) -> int:
312
+ parser = build_parser()
313
+ args = parser.parse_args(argv)
314
+
315
+ if args.command is None:
316
+ parser.print_help()
317
+ return 2
318
+
319
+ try:
320
+ return _dispatch(args, parser)
321
+ except (ProjectError, ConfigError, AuthError, ApiError, SecretError,
322
+ WalletError) as exc:
323
+ # EVERY ONE OF THESE IS ALREADY PHRASED FOR THE DEVELOPER, and none is
324
+ # about this file. A traceback here would name the one place the
325
+ # problem is not.
326
+ print(f"error: {exc}", file=sys.stderr)
327
+ return 2
328
+
329
+
330
+ def _dispatch(args, parser) -> int:
331
+ if args.command == "init":
332
+ return scaffold.init(args.directory or Path.cwd(), tenant=args.tenant,
333
+ project=args.project, repo=args.repo,
334
+ agent=args.agent)
335
+ if args.command == "new":
336
+ return scaffold.new(args.kind, args.slug)
337
+ if args.command == "adk":
338
+ return adk_cmd.run(args.args)
339
+
340
+ if args.command == "env":
341
+ return env.run()
342
+
343
+ if args.command == "validate":
344
+ return validate.run(python=args.python)
345
+
346
+ if args.command == "login":
347
+ return _login(_config_for_login(args.api))
348
+ if args.command == "logout":
349
+ config = Config.load()
350
+ if auth.forget(config.issuer):
351
+ print(f"signed out of {config.issuer}")
352
+ else:
353
+ print("not signed in")
354
+ return 0
355
+ if args.command == "whoami":
356
+ return _whoami(Config.load())
357
+
358
+ if args.command == "secret":
359
+ if not args.secret_command:
360
+ parser.parse_args(["secret", "--help"])
361
+ return 2
362
+ config = Config.load()
363
+ api = Api(config)
364
+ try:
365
+ if args.secret_command == "set":
366
+ return secret.set_(api, args.kind, args.name, args.from_file)
367
+ if args.secret_command == "rm":
368
+ return secret.rm(api, args.kind, args.name)
369
+ return secret.ls(api)
370
+ finally:
371
+ api.close()
372
+
373
+ if args.command == "files":
374
+ if not args.files_command:
375
+ parser.parse_args(["files", "--help"])
376
+ return 2
377
+ api = Api(Config.load())
378
+ try:
379
+ return {"ls": files.ls, "get": files.get,
380
+ "put": files.put, "rm": files.rm}[args.files_command](args, api)
381
+ except (files.FilesError, ApiError) as exc:
382
+ print(f"error: {exc}", file=sys.stderr)
383
+ return 1
384
+
385
+ if args.command == "wallet":
386
+ if not args.wallet_command:
387
+ parser.parse_args(["wallet", "--help"])
388
+ return 2
389
+ config = Config.load()
390
+ api = Api(config)
391
+ try:
392
+ what = args.wallet_command
393
+ # RESOLVED ONCE, HERE. Every subcommand needs it and every one of
394
+ # them would otherwise have to remember to look it up.
395
+ project = wallet.project_of(args.project)
396
+ if what == "show":
397
+ return wallet.show(api, project, args.user)
398
+ if what == "create":
399
+ return wallet.create(api, project, args.user, args.label)
400
+ if what == "credit":
401
+ return wallet.credit(api, project, args.user, args.amount,
402
+ args.micros, args.ref)
403
+ if what == "adjust":
404
+ return wallet.adjust(api, project, args.user, args.amount,
405
+ args.micros, args.ref)
406
+ if what == "freeze":
407
+ return wallet.status(api, project, args.user, "frozen")
408
+ if what == "unfreeze":
409
+ return wallet.status(api, project, args.user, "active")
410
+ if what == "statement":
411
+ return wallet.statement(api, project, args.user, args.limit)
412
+ return wallet.ls(api, project, args.limit)
413
+ finally:
414
+ api.close()
415
+
416
+ if args.command == "tenant":
417
+ if not args.tenant_command:
418
+ parser.parse_args(["tenant", "--help"])
419
+ return 2
420
+ config = Config.load()
421
+ api = Api(config)
422
+ try:
423
+ if args.tenant_command == "create":
424
+ return tenant.create(api, args.slug, args.name)
425
+ return tenant.ls(api)
426
+ finally:
427
+ api.close()
428
+
429
+ if args.command == "projects":
430
+ if not args.projects_command:
431
+ parser.parse_args(["projects", "--help"])
432
+ return 2
433
+ config = Config.load()
434
+ api = Api(config)
435
+ try:
436
+ if args.projects_command == "create":
437
+ return projects_cmd.create(api, args.slug, args.name, args.repo)
438
+ return projects_cmd.ls(api)
439
+ finally:
440
+ api.close()
441
+
442
+ if args.command == "agents":
443
+ config = Config.load()
444
+ api = Api(config)
445
+ try:
446
+ return projects_cmd.agents(api, args.project)
447
+ finally:
448
+ api.close()
449
+
450
+ if args.command == "doctor":
451
+ config = Config.load()
452
+ api = Api(config)
453
+ try:
454
+ return doctor_cmd.run(api)
455
+ finally:
456
+ api.close()
457
+
458
+ if args.command == "destroy":
459
+ config = Config.load()
460
+ api = Api(config)
461
+ try:
462
+ return destroy_cmd.run(api, args.agent,
463
+ whole_project=args.project,
464
+ assume_yes=args.yes)
465
+ finally:
466
+ api.close()
467
+
468
+ if args.command == "invoke":
469
+ config = Config.load()
470
+ api = Api(config)
471
+ try:
472
+ # FROM elan.yaml, WHICH IS THE ONLY PLACE THAT KNOWS. `invoke` is
473
+ # run inside a project, and the deploy file names it — so this is
474
+ # read rather than asked for, the same way `deploy` reads it.
475
+ return invoke_cmd.run(api, args.target, args.text,
476
+ session=args.session,
477
+ project=_project_here(),
478
+ timeout=args.timeout)
479
+ finally:
480
+ api.close()
481
+
482
+ if args.command == "logs":
483
+ if bool(args.deployment_id) == bool(args.workload):
484
+ # BOTH OR NEITHER IS THE AMBIGUOUS CASE, and the two logs come from
485
+ # different places — a build that has finished, and a pod that is
486
+ # running. Guessing would print the wrong one convincingly.
487
+ print("Name one: `elan logs <deployment-id>` for a build, or "
488
+ "`elan logs --workload <name> --project <slug>` for what a "
489
+ "running workload is saying.", file=sys.stderr)
490
+ return 2
491
+ config = Config.load()
492
+ api = Api(config)
493
+ try:
494
+ if args.workload:
495
+ return logs_cmd.workload(
496
+ api, args.workload, project=args.project,
497
+ minutes=args.since, probes=args.probes, tail=args.tail)
498
+ return logs_cmd.run(api, args.deployment_id, tail=args.tail)
499
+ finally:
500
+ api.close()
501
+
502
+ if args.command == "rollback":
503
+ config = Config.load()
504
+ api = Api(config)
505
+ try:
506
+ return rollback.run(api, args.to_deployment_id,
507
+ wait=not args.no_wait, assume_yes=args.yes)
508
+ finally:
509
+ api.close()
510
+
511
+ if args.command in ("deploy", "status"):
512
+ config = Config.load()
513
+ api = Api(config)
514
+ try:
515
+ if args.command == "deploy":
516
+ return deploy.run(api, ref=args.ref, wait=not args.no_wait)
517
+ return deploy.status(api, args.deployment_id)
518
+ finally:
519
+ api.close()
520
+
521
+ parser.print_help()
522
+ return 2
523
+
524
+
525
+ def _config_for_login(api_override: str) -> Config:
526
+ """The platform's URL, then everything else FROM the platform.
527
+
528
+ `--api` is the one thing a developer can supply and the only thing they
529
+ should have to know. Asking them for an issuer, a client id and a generated
530
+ project id was the original design and it was wrong: those are the
531
+ platform's own facts, they are all public, and a tool that makes somebody
532
+ paste four of them has acquired a setup page.
533
+ """
534
+ if api_override:
535
+ config = Config(api=api_override.rstrip("/"))
536
+ else:
537
+ config = Config.load()
538
+
539
+ discovered = auth.platform_auth(config.api)
540
+ config = config.with_auth(discovered)
541
+ config.save()
542
+ return config
543
+
544
+
545
+ def _project_here() -> str:
546
+ """The project named by the elan.yaml this command is being run inside.
547
+
548
+ THE ONLY PLACE THAT KNOWS. A topic is
549
+ `agent.invoke.<tenant>.<project>.<agent>` and ADK keys a session on
550
+ `<tenant>.<project>.<agent>`, so a command that does not send the project
551
+ is addressing the PLATFORM's own — which under a customer's tenant names
552
+ nothing at all.
553
+
554
+ EMPTY RATHER THAN AN ERROR WHEN THERE IS NO SPEC. `elan invoke` from
555
+ outside a project is how somebody pokes the platform's own canary, and
556
+ that really is `platform/canary` — which is exactly what the services
557
+ default to.
558
+ """
559
+ try:
560
+ return find().spec.project
561
+ except ProjectError:
562
+ return ""
563
+
564
+
565
+ def _login(config: Config) -> int:
566
+ print(f"signing in to {config.api}")
567
+ device = auth.begin(config)
568
+
569
+ # OUR OWN PAGE, NOT THE ISSUER'S `verification_uri`.
570
+ #
571
+ # The issuer answers with a URL on `zitadel.<domain>` whatever the login-UI
572
+ # setting says. Following it sends a developer to a vendor's page on a
573
+ # hostname they never typed — and a security key enrolled there is bound to
574
+ # THAT origin, so the console can never assert it and the account is locked
575
+ # out of the console until an administrator deletes the factor. That
576
+ # happened twice here.
577
+ #
578
+ # FALLS BACK TO THE ISSUER'S when the platform did not name one, because a
579
+ # sign-in that works on the wrong page beats one that cannot start.
580
+ ours = config.device_url
581
+ complete = (f"{ours}?user_code={device['user_code']}" if ours
582
+ else device.get("verification_uri_complete"))
583
+
584
+ print("To sign in, open:\n")
585
+ print(f" {complete or device['verification_uri']}\n")
586
+ if not complete:
587
+ print(f"and enter the code: {device['user_code']}\n")
588
+ else:
589
+ # PRINTED ANYWAY. The complete URI carries the code, but a developer
590
+ # copying a long URL into a phone will often type the code instead —
591
+ # and being unable to find it at that point is a dead end.
592
+ print(f"(the code, if asked: {device['user_code']})\n")
593
+ print("Waiting...")
594
+
595
+ tokens = auth.poll(config, device)
596
+ if not tokens.refresh_token:
597
+ # WORTH SAYING RATHER THAN SILENTLY DEGRADING. Without a refresh token
598
+ # every command in an hour needs a browser again, and the developer
599
+ # would blame the CLI rather than the missing `offline_access` grant.
600
+ print("\nwarning: the issuer returned no refresh token, so this "
601
+ "session cannot be renewed.\n The CLI's application "
602
+ "needs the refresh_token grant.", file=sys.stderr)
603
+ else:
604
+ auth.store(config.issuer, tokens.refresh_token)
605
+ # AFTER `store`, WHICH CLEARS THE CACHE, and this login is the one
606
+ # moment a live access token is already in hand.
607
+ #
608
+ # WITHOUT THIS, A BURST RIGHT AFTER LOGIN REVOKES THE SESSION. `store`
609
+ # deliberately drops any cached access token, so the cache is empty
610
+ # exactly here — and several `elan` processes starting at once then all
611
+ # miss it, all call `refresh` with the SAME refresh token, and an issuer
612
+ # that rotates treats the second use as a breach and revokes the whole
613
+ # grant. Measured: a login followed by a parallel load test, and every
614
+ # command afterwards failed with "your session is no longer valid".
615
+ #
616
+ # Caching the token we were just given means the burst reads it instead
617
+ # of racing for a new one.
618
+ auth.cache_access(config.issuer, tokens)
619
+
620
+ identity = auth.claims(tokens.access_token)
621
+ print(f"\nsigned in as {identity.get('sub')}")
622
+ print(f" tenant {identity.get('tenant_id') or '(none in this token)'}")
623
+ if not identity.get("tenant_id"):
624
+ # THE ONE CLAIM EVERY ROUTE IS SCOPED BY. Without it the services
625
+ # refuse every request, and the cause is an issuer-side action rather
626
+ # than anything the developer did.
627
+ print("\nwarning: this token carries no `tenant_id`, so every request "
628
+ "will be refused.\n That claim is added by an action on "
629
+ "the issuer, not by the CLI.", file=sys.stderr)
630
+ return 0
631
+
632
+
633
+ def _whoami(config: Config) -> int:
634
+ if not config.signed_in_possible:
635
+ print("not signed in. Run `elan login`.")
636
+ return 1
637
+ if not auth.stored(config.issuer):
638
+ print("not signed in. Run `elan login`.")
639
+ return 1
640
+
641
+ # THROUGH `Api.token()` RATHER THAN `auth.refresh()` DIRECTLY. This called
642
+ # refresh unconditionally, so `elan whoami` — the command somebody runs to
643
+ # CHECK a session — rotated the refresh token every time it ran, and cached
644
+ # nothing. On an issuer that rotates, a diagnostic that mutates the thing it
645
+ # reports on is the worst shape available: it also left the next command to
646
+ # race for a token this one had already invalidated.
647
+ #
648
+ # `Api.token()` consults the cache first and writes it back, so this is now
649
+ # free when a session is warm and one refresh when it is not.
650
+ try:
651
+ access = Api(config).token()
652
+ except ApiError as exc:
653
+ print(str(exc))
654
+ return 1
655
+ identity = auth.claims(access)
656
+ print(f"{identity.get('sub')}")
657
+ print(f" tenant {identity.get('tenant_id') or '(none)'}")
658
+ print(f" issuer {config.issuer}")
659
+ print(f" api {config.api}")
660
+ return 0
661
+
662
+
663
+ if __name__ == "__main__":
664
+ raise SystemExit(run())