publinker-cli 0.6.2__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.
- publinker_cli/__init__.py +14 -0
- publinker_cli/__main__.py +83 -0
- publinker_cli/audience.py +73 -0
- publinker_cli/auth.py +152 -0
- publinker_cli/client.py +166 -0
- publinker_cli/commands/__init__.py +0 -0
- publinker_cli/commands/auth_cmds.py +69 -0
- publinker_cli/commands/report_options.py +100 -0
- publinker_cli/commands/skill.py +46 -0
- publinker_cli/commands/token_cmd.py +97 -0
- publinker_cli/commands/update.py +207 -0
- publinker_cli/commands/use.py +100 -0
- publinker_cli/commands/write.py +72 -0
- publinker_cli/config.py +64 -0
- publinker_cli/dispatch_cli.py +394 -0
- publinker_cli/keyring_store.py +109 -0
- publinker_cli/manifest.py +409 -0
- publinker_cli/routes.yaml +1648 -0
- publinker_cli/skill/SKILL.md +61 -0
- publinker_cli/state.py +138 -0
- publinker_cli/version.py +58 -0
- publinker_cli/write_gate.py +51 -0
- publinker_cli-0.6.2.dist-info/METADATA +81 -0
- publinker_cli-0.6.2.dist-info/RECORD +26 -0
- publinker_cli-0.6.2.dist-info/WHEEL +4 -0
- publinker_cli-0.6.2.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""`publinker skill` — install the agent skill that teaches AI assistants the CLI.
|
|
2
|
+
|
|
3
|
+
The skill is a plain SKILL.md packaged with the CLI, so it always matches the
|
|
4
|
+
installed version. `install` copies it to the user-level skill folders that
|
|
5
|
+
Claude Code and Codex read; `path` prints it for any other assistant.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from importlib import resources
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(
|
|
16
|
+
name="skill",
|
|
17
|
+
help="Install the Publinker skill for Claude Code and Codex.",
|
|
18
|
+
no_args_is_help=True,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
SKILL_NAME = "publinker"
|
|
22
|
+
# User-level skill folders: Claude Code reads ~/.claude/skills, Codex reads
|
|
23
|
+
# ~/.agents/skills. Both take <name>/SKILL.md.
|
|
24
|
+
TARGETS = (Path(".claude/skills"), Path(".agents/skills"))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def packaged_skill() -> Path:
|
|
28
|
+
return Path(str(resources.files("publinker_cli") / "skill" / "SKILL.md"))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@app.command(
|
|
32
|
+
"install", help="Copy the skill into ~/.claude/skills and ~/.agents/skills."
|
|
33
|
+
)
|
|
34
|
+
def install_cmd() -> None:
|
|
35
|
+
text = packaged_skill().read_text(encoding="utf-8")
|
|
36
|
+
for target in TARGETS:
|
|
37
|
+
dest = Path.home() / target / SKILL_NAME / "SKILL.md"
|
|
38
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
dest.write_text(text, encoding="utf-8")
|
|
40
|
+
typer.echo(f"installed {dest}")
|
|
41
|
+
typer.echo("Restart Claude Code / Codex to pick it up.")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@app.command("path", help="Print the packaged SKILL.md path (for other assistants).")
|
|
45
|
+
def path_cmd() -> None:
|
|
46
|
+
typer.echo(packaged_skill())
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""`publinker token` — print the live access/id token for scripting and debugging.
|
|
2
|
+
|
|
3
|
+
Exists so nobody has to dig a session token out of DevTools → Application →
|
|
4
|
+
Local Storage to authenticate a curl or a preview host (the workflow this
|
|
5
|
+
replaces; see DEV-1361's per-PR preview environments).
|
|
6
|
+
|
|
7
|
+
Contract, deliberately Unix-ish:
|
|
8
|
+
- **stdout carries the token and nothing else** — no banner, no color, no
|
|
9
|
+
spinner — so `$(publinker token)` is safe to embed in a command.
|
|
10
|
+
- **All human messaging goes to stderr**, so it stays visible in a terminal but
|
|
11
|
+
never contaminates a pipe.
|
|
12
|
+
- The token is never written to a file, a log, or the shell history by this
|
|
13
|
+
command; printing it to stdout is the caller's decision to make.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import time
|
|
20
|
+
|
|
21
|
+
import typer
|
|
22
|
+
from rich.console import Console
|
|
23
|
+
|
|
24
|
+
from publinker_cli import keyring_store
|
|
25
|
+
from publinker_cli.client import NotAuthenticatedError, PublinkerClient
|
|
26
|
+
from publinker_cli.config import AUTH0_AUDIENCE, AUTH0_SCOPES
|
|
27
|
+
|
|
28
|
+
# stderr so it can never pollute `$(publinker token)`.
|
|
29
|
+
err_console = Console(stderr=True)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def token_command(
|
|
33
|
+
id_token: bool = typer.Option(
|
|
34
|
+
False,
|
|
35
|
+
"--id",
|
|
36
|
+
help="Print the id_token (identity claims) instead of the access token.",
|
|
37
|
+
),
|
|
38
|
+
as_json: bool = typer.Option(
|
|
39
|
+
False,
|
|
40
|
+
"--json",
|
|
41
|
+
help=(
|
|
42
|
+
"Print the full bundle as JSON: access_token, id_token, token_type, "
|
|
43
|
+
"scope, audience, expires_at, expires_in."
|
|
44
|
+
),
|
|
45
|
+
),
|
|
46
|
+
) -> None:
|
|
47
|
+
"""Print a live Auth0 token for the logged-in user.
|
|
48
|
+
|
|
49
|
+
Refreshes transparently when the cached token has expired, so the value
|
|
50
|
+
printed is always usable right now.
|
|
51
|
+
"""
|
|
52
|
+
if id_token and as_json:
|
|
53
|
+
err_console.print("[red]--id and --json are mutually exclusive.[/red]")
|
|
54
|
+
raise typer.Exit(code=2)
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
# Reuse the client's refresh-and-persist path rather than duplicating it:
|
|
58
|
+
# current_bundle() returns a non-expired bundle, refreshing (and
|
|
59
|
+
# re-saving) when needed, and raises when the session is truly gone.
|
|
60
|
+
bundle = PublinkerClient().current_bundle()
|
|
61
|
+
except NotAuthenticatedError as exc:
|
|
62
|
+
err_console.print(f"[red]{exc}[/red]")
|
|
63
|
+
raise typer.Exit(code=1)
|
|
64
|
+
|
|
65
|
+
if as_json:
|
|
66
|
+
typer.echo(json.dumps(_bundle_as_dict(bundle), indent=2))
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
value = bundle.id_token if id_token else bundle.access_token
|
|
70
|
+
if not value:
|
|
71
|
+
which = "id_token" if id_token else "access_token"
|
|
72
|
+
err_console.print(
|
|
73
|
+
f"[red]No {which} in the stored session — run `publinker login`.[/red]"
|
|
74
|
+
)
|
|
75
|
+
raise typer.Exit(code=1)
|
|
76
|
+
|
|
77
|
+
typer.echo(value)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _bundle_as_dict(bundle: keyring_store.TokenBundle) -> dict:
|
|
81
|
+
"""Shape the bundle like Auth0's own /oauth/token response.
|
|
82
|
+
|
|
83
|
+
Mirroring that shape means the JSON can be dropped straight into tooling
|
|
84
|
+
that already understands an Auth0 token response. `expires_in` is recomputed
|
|
85
|
+
from the stored absolute expiry so it is accurate at print time, not at
|
|
86
|
+
login time.
|
|
87
|
+
"""
|
|
88
|
+
now = int(time.time())
|
|
89
|
+
return {
|
|
90
|
+
"access_token": bundle.access_token,
|
|
91
|
+
"id_token": bundle.id_token,
|
|
92
|
+
"token_type": "Bearer",
|
|
93
|
+
"scope": AUTH0_SCOPES,
|
|
94
|
+
"audience": AUTH0_AUDIENCE,
|
|
95
|
+
"expires_at": bundle.expires_at,
|
|
96
|
+
"expires_in": max(0, bundle.expires_at - now),
|
|
97
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""`publinker update` — check for and install a newer publinker-cli (DEV-1973).
|
|
2
|
+
|
|
3
|
+
The release source is the git spec pipx recorded when this installation was
|
|
4
|
+
made (`install-cli.sh` records a `git+https://...@<ref>#subdirectory=cli` spec).
|
|
5
|
+
The latest version is the `__version__` in `publinker_cli/__init__.py` at that
|
|
6
|
+
ref, read with the same git credentials pipx installs with — no Publinker
|
|
7
|
+
login, no AWS credentials, no write gate.
|
|
8
|
+
|
|
9
|
+
Only the running pipx installation is ever modified, and only from the source
|
|
10
|
+
it was installed from. Editable/local-path/wheel installs and customer builds
|
|
11
|
+
get instructions instead: a customer wheel must never be replaced by the
|
|
12
|
+
internal build that lives in the repository.
|
|
13
|
+
|
|
14
|
+
Exit codes:
|
|
15
|
+
- 0 up to date, or updated and verified
|
|
16
|
+
- 10 `--check` only: a newer version is available
|
|
17
|
+
- 1 anything else (unsupported install, metadata/network/access/installer
|
|
18
|
+
failure); nothing was installed or the install could not be verified
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
import shutil
|
|
27
|
+
import subprocess
|
|
28
|
+
import sys
|
|
29
|
+
import tempfile
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Optional
|
|
33
|
+
|
|
34
|
+
import typer
|
|
35
|
+
|
|
36
|
+
from publinker_cli import __version__, audience
|
|
37
|
+
from publinker_cli.version import is_newer, parse_version
|
|
38
|
+
|
|
39
|
+
PACKAGE = "publinker-cli"
|
|
40
|
+
EXIT_UPDATE_AVAILABLE = 10
|
|
41
|
+
INSTALLER_HINT = (
|
|
42
|
+
"Install with ./cli/scripts/install-cli.sh from a publinker-api checkout "
|
|
43
|
+
"(a pipx install from git), then `publinker update` can manage it."
|
|
44
|
+
)
|
|
45
|
+
_GIT_SPEC_RE = re.compile(
|
|
46
|
+
r"^git\+(?P<url>[^@#]+?)(?:@(?P<ref>[^#]+))?#subdirectory=cli$"
|
|
47
|
+
)
|
|
48
|
+
_INIT_VERSION_RE = re.compile(r'^__version__ = "([^"]+)"', re.M)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class UpdateError(Exception):
|
|
52
|
+
"""A failure the user must act on. The message says how."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class Installation:
|
|
57
|
+
pipx: str
|
|
58
|
+
spec: str
|
|
59
|
+
url: str
|
|
60
|
+
ref: Optional[str]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _run(cmd: list[str], **kwargs) -> subprocess.CompletedProcess:
|
|
64
|
+
# GIT_TERMINAL_PROMPT=0: a missing credential fails instead of hanging.
|
|
65
|
+
env = {**os.environ, "GIT_TERMINAL_PROMPT": "0"}
|
|
66
|
+
return subprocess.run(cmd, text=True, env=env, **kwargs)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def find_installation() -> Installation:
|
|
70
|
+
"""The pipx install that is running this process, or UpdateError."""
|
|
71
|
+
if audience.is_customer_build():
|
|
72
|
+
raise UpdateError(
|
|
73
|
+
"This is a customer build of publinker-cli; update it with "
|
|
74
|
+
"`pipx upgrade publinker-cli`."
|
|
75
|
+
)
|
|
76
|
+
pipx = shutil.which("pipx")
|
|
77
|
+
if not pipx:
|
|
78
|
+
raise UpdateError(f"pipx not found on PATH. {INSTALLER_HINT}")
|
|
79
|
+
venvs = _run(
|
|
80
|
+
[pipx, "environment", "--value", "PIPX_LOCAL_VENVS"], capture_output=True
|
|
81
|
+
)
|
|
82
|
+
if (
|
|
83
|
+
venvs.returncode != 0
|
|
84
|
+
or Path(sys.prefix).resolve()
|
|
85
|
+
!= (Path(venvs.stdout.strip()) / PACKAGE).resolve()
|
|
86
|
+
):
|
|
87
|
+
raise UpdateError(
|
|
88
|
+
f"This publinker ({sys.prefix}) is not a pipx installation; "
|
|
89
|
+
f"update it where it was installed (e.g. `git pull` for an editable "
|
|
90
|
+
f"install). {INSTALLER_HINT}"
|
|
91
|
+
)
|
|
92
|
+
listing = _run([pipx, "list", "--json"], capture_output=True)
|
|
93
|
+
try:
|
|
94
|
+
spec = json.loads(listing.stdout)["venvs"][PACKAGE]["metadata"]["main_package"][
|
|
95
|
+
"package_or_url"
|
|
96
|
+
]
|
|
97
|
+
except (ValueError, KeyError, TypeError) as exc:
|
|
98
|
+
raise UpdateError(f"could not read the pipx install record: {exc}") from exc
|
|
99
|
+
match = _GIT_SPEC_RE.match(spec)
|
|
100
|
+
if not match:
|
|
101
|
+
raise UpdateError(
|
|
102
|
+
f"publinker-cli was installed from {spec}, which has no release "
|
|
103
|
+
f"source to check. {INSTALLER_HINT}"
|
|
104
|
+
)
|
|
105
|
+
return Installation(pipx, spec, match["url"], match["ref"])
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def latest_version(inst: Installation) -> str:
|
|
109
|
+
"""`__version__` of cli/ at the installation's git ref."""
|
|
110
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
111
|
+
clone = [
|
|
112
|
+
"git",
|
|
113
|
+
"clone",
|
|
114
|
+
"--quiet",
|
|
115
|
+
"--depth",
|
|
116
|
+
"1",
|
|
117
|
+
"--filter=blob:none",
|
|
118
|
+
"--no-checkout",
|
|
119
|
+
]
|
|
120
|
+
if inst.ref:
|
|
121
|
+
clone += ["--branch", inst.ref]
|
|
122
|
+
cloned = _run(clone + [inst.url, tmp], capture_output=True)
|
|
123
|
+
if cloned.returncode != 0:
|
|
124
|
+
raise UpdateError(
|
|
125
|
+
f"could not read {inst.url}@{inst.ref or 'HEAD'}: "
|
|
126
|
+
f"{cloned.stderr.strip() or 'git clone failed'}"
|
|
127
|
+
)
|
|
128
|
+
shown = _run(
|
|
129
|
+
["git", "-C", tmp, "show", "HEAD:cli/publinker_cli/__init__.py"],
|
|
130
|
+
capture_output=True,
|
|
131
|
+
)
|
|
132
|
+
found = _INIT_VERSION_RE.search(shown.stdout) if shown.returncode == 0 else None
|
|
133
|
+
if not found:
|
|
134
|
+
raise UpdateError(
|
|
135
|
+
f"no publinker-cli version found at {inst.url}@{inst.ref or 'HEAD'}"
|
|
136
|
+
)
|
|
137
|
+
try:
|
|
138
|
+
parse_version(found[1])
|
|
139
|
+
except ValueError as exc:
|
|
140
|
+
raise UpdateError(f"release source has a malformed version: {exc}") from exc
|
|
141
|
+
return found[1]
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def installed_version() -> str:
|
|
145
|
+
"""Version reported by this installation's own binary, after a reinstall."""
|
|
146
|
+
binary = Path(sys.prefix) / "bin" / "publinker"
|
|
147
|
+
out = _run([str(binary), "--version"], capture_output=True)
|
|
148
|
+
if out.returncode != 0:
|
|
149
|
+
raise UpdateError(f"{binary} --version failed: {out.stderr.strip()}")
|
|
150
|
+
reported = out.stdout.strip().rsplit(" ", 1)[-1]
|
|
151
|
+
try:
|
|
152
|
+
parse_version(reported)
|
|
153
|
+
except ValueError as exc:
|
|
154
|
+
raise UpdateError(f"could not verify the installed version: {exc}") from exc
|
|
155
|
+
return reported
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def update_command(
|
|
159
|
+
check: bool = typer.Option(
|
|
160
|
+
False,
|
|
161
|
+
"--check",
|
|
162
|
+
help="Only report installed/latest versions as JSON; never install.",
|
|
163
|
+
),
|
|
164
|
+
) -> None:
|
|
165
|
+
"""Check for a newer publinker-cli and install it into this pipx install.
|
|
166
|
+
|
|
167
|
+
Needs no Publinker login. Exit codes: 0 current/updated, 10 update
|
|
168
|
+
available (--check), 1 failure.
|
|
169
|
+
"""
|
|
170
|
+
try:
|
|
171
|
+
inst = find_installation()
|
|
172
|
+
latest = latest_version(inst)
|
|
173
|
+
available = is_newer(latest)
|
|
174
|
+
if check:
|
|
175
|
+
typer.echo(
|
|
176
|
+
json.dumps(
|
|
177
|
+
{
|
|
178
|
+
"installed": __version__,
|
|
179
|
+
"latest": latest,
|
|
180
|
+
"update_available": available,
|
|
181
|
+
"source": inst.spec,
|
|
182
|
+
}
|
|
183
|
+
)
|
|
184
|
+
)
|
|
185
|
+
raise typer.Exit(EXIT_UPDATE_AVAILABLE if available else 0)
|
|
186
|
+
if not available:
|
|
187
|
+
typer.echo(f"publinker-cli {__version__} is up to date ({inst.spec}).")
|
|
188
|
+
return
|
|
189
|
+
typer.echo(f"Updating publinker-cli {__version__} -> {latest} ...", err=True)
|
|
190
|
+
# Installer logs go to stderr; stdout stays the one-line result.
|
|
191
|
+
reinstall = _run(
|
|
192
|
+
[inst.pipx, "reinstall", PACKAGE], stdout=sys.stderr, stderr=sys.stderr
|
|
193
|
+
)
|
|
194
|
+
if reinstall.returncode != 0:
|
|
195
|
+
raise UpdateError(
|
|
196
|
+
f"pipx reinstall failed (exit {reinstall.returncode}); "
|
|
197
|
+
f"publinker-cli may need `pipx install --force '{inst.spec}'`."
|
|
198
|
+
)
|
|
199
|
+
now = installed_version()
|
|
200
|
+
if parse_version(now) < parse_version(latest):
|
|
201
|
+
raise UpdateError(
|
|
202
|
+
f"installed version is {now}, expected {latest}; update not verified."
|
|
203
|
+
)
|
|
204
|
+
typer.echo(f"publinker-cli updated {__version__} -> {now}.")
|
|
205
|
+
except UpdateError as exc:
|
|
206
|
+
typer.echo(f"publinker update: {exc}", err=True)
|
|
207
|
+
raise typer.Exit(1)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""`publinker use <site>` — set the active site for subsequent commands.
|
|
2
|
+
|
|
3
|
+
Persists to ~/.config/publinker/state.json. Validates the site is in the
|
|
4
|
+
user's permission set against `GET /site` so typos surface immediately.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
|
|
14
|
+
from publinker_cli import state
|
|
15
|
+
from publinker_cli.config import available_stages
|
|
16
|
+
from publinker_cli.client import APIError, NotAuthenticatedError, PublinkerClient
|
|
17
|
+
|
|
18
|
+
console = Console()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def use_command(
|
|
22
|
+
site: Optional[str] = typer.Argument(
|
|
23
|
+
None,
|
|
24
|
+
help=(
|
|
25
|
+
"Site domain to make active. Subsequent site-scoped commands "
|
|
26
|
+
"default to this site. Use --clear to unset."
|
|
27
|
+
),
|
|
28
|
+
),
|
|
29
|
+
clear: bool = typer.Option(
|
|
30
|
+
False,
|
|
31
|
+
"--clear",
|
|
32
|
+
"-c",
|
|
33
|
+
help="Clear the active site instead of setting one.",
|
|
34
|
+
),
|
|
35
|
+
no_verify: bool = typer.Option(
|
|
36
|
+
False,
|
|
37
|
+
"--no-verify",
|
|
38
|
+
help=(
|
|
39
|
+
"Skip the API permission check. Useful when your permissions "
|
|
40
|
+
"just changed and the cached token hasn't refreshed."
|
|
41
|
+
),
|
|
42
|
+
),
|
|
43
|
+
stage: str = typer.Option(
|
|
44
|
+
"prod",
|
|
45
|
+
"--stage",
|
|
46
|
+
"-s",
|
|
47
|
+
help=f"API stage to verify against: {' | '.join(available_stages())}",
|
|
48
|
+
),
|
|
49
|
+
) -> None:
|
|
50
|
+
"""Set or clear the active publinker site (persisted across sessions)."""
|
|
51
|
+
if clear:
|
|
52
|
+
if site:
|
|
53
|
+
console.print(
|
|
54
|
+
"[red]error:[/red] --clear takes no argument. Use either "
|
|
55
|
+
"`publinker use <site>` OR `publinker use --clear`."
|
|
56
|
+
)
|
|
57
|
+
raise typer.Exit(code=2)
|
|
58
|
+
state.clear()
|
|
59
|
+
console.print("[green]✓[/green] Active site cleared.")
|
|
60
|
+
return
|
|
61
|
+
|
|
62
|
+
if not site:
|
|
63
|
+
current = state.active_site()
|
|
64
|
+
if current:
|
|
65
|
+
console.print(f"Active site: [cyan]{current}[/cyan]")
|
|
66
|
+
else:
|
|
67
|
+
console.print(
|
|
68
|
+
"[yellow]No active site set.[/yellow] "
|
|
69
|
+
"Run `publinker use <domain>` to set one."
|
|
70
|
+
)
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
if not no_verify:
|
|
74
|
+
try:
|
|
75
|
+
sites = PublinkerClient(stage=stage).list_sites()
|
|
76
|
+
except NotAuthenticatedError as exc:
|
|
77
|
+
console.print(f"[red]{exc}[/red]")
|
|
78
|
+
raise typer.Exit(code=1)
|
|
79
|
+
except (APIError, ValueError) as exc:
|
|
80
|
+
console.print(f"[red]{exc}[/red]")
|
|
81
|
+
raise typer.Exit(code=1)
|
|
82
|
+
if site not in sites:
|
|
83
|
+
console.print(
|
|
84
|
+
f"[red]error:[/red] [cyan]{site}[/cyan] is not in your permission "
|
|
85
|
+
f"set on stage [cyan]{stage}[/cyan]."
|
|
86
|
+
)
|
|
87
|
+
if sites:
|
|
88
|
+
console.print(
|
|
89
|
+
"\nYou have access to "
|
|
90
|
+
f"[cyan]{len(sites)}[/cyan] site(s). "
|
|
91
|
+
"Run [bold]publinker site list[/bold] to see them."
|
|
92
|
+
)
|
|
93
|
+
console.print(
|
|
94
|
+
"\nIf your permissions just changed and the cached token hasn't "
|
|
95
|
+
"refreshed, pass [bold]--no-verify[/bold] to skip the check."
|
|
96
|
+
)
|
|
97
|
+
raise typer.Exit(code=1)
|
|
98
|
+
|
|
99
|
+
state.set_active_site(site)
|
|
100
|
+
console.print(f"[green]✓[/green] Active site set to [cyan]{site}[/cyan].")
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""`publinker write` diagnostics for the process-scoped mutation gate.
|
|
2
|
+
|
|
3
|
+
File-backed allow windows are retired. A reviewed mutation carries
|
|
4
|
+
``PUBLINKER_ALLOW_WRITES=1`` on that one command; nothing stays armed afterward.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
|
|
14
|
+
from publinker_cli import write_gate
|
|
15
|
+
from publinker_cli.config import available_stages
|
|
16
|
+
|
|
17
|
+
app = typer.Typer(
|
|
18
|
+
name="write",
|
|
19
|
+
help="Inspect the process-scoped mutation gate and clear retired state.",
|
|
20
|
+
no_args_is_help=True,
|
|
21
|
+
)
|
|
22
|
+
console = Console()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@app.command("status", help="Show whether this process is authorized to mutate.")
|
|
26
|
+
def status_cmd() -> None:
|
|
27
|
+
gate = write_gate.status()
|
|
28
|
+
if gate.allowed:
|
|
29
|
+
console.print(
|
|
30
|
+
f"[green]Writes ALLOWED[/green] — {gate.reason}; this process only"
|
|
31
|
+
)
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
console.print(f"[red]Writes BLOCKED[/red] — {gate.reason}.")
|
|
35
|
+
console.print("Authorize one reviewed command inline:")
|
|
36
|
+
stage = " --stage <stage>" if len(available_stages()) > 1 else ""
|
|
37
|
+
console.print(
|
|
38
|
+
f" [cyan]{write_gate.ENV_VAR}=1 publinker <mutation>{stage} ...[/cyan]"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@app.command("allow", help="Explain the one-process write authorization pattern.")
|
|
43
|
+
def allow_cmd(
|
|
44
|
+
minutes: Optional[int] = typer.Option(
|
|
45
|
+
None,
|
|
46
|
+
"--minutes",
|
|
47
|
+
"-m",
|
|
48
|
+
help="Retired compatibility option; persistent windows are disabled.",
|
|
49
|
+
),
|
|
50
|
+
persistent: bool = typer.Option(
|
|
51
|
+
False,
|
|
52
|
+
"--persistent",
|
|
53
|
+
help="Retired compatibility option; persistent authorization is disabled.",
|
|
54
|
+
),
|
|
55
|
+
) -> None:
|
|
56
|
+
del minutes, persistent
|
|
57
|
+
console.print(
|
|
58
|
+
"[red]Persistent write windows are disabled.[/red] Prefix one reviewed "
|
|
59
|
+
f"mutation with [cyan]{write_gate.ENV_VAR}=1[/cyan] instead."
|
|
60
|
+
)
|
|
61
|
+
raise typer.Exit(code=2)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@app.command("block", help="Clear retired file-backed write authorization.")
|
|
65
|
+
def block_cmd() -> None:
|
|
66
|
+
write_gate.clear_legacy_state()
|
|
67
|
+
console.print("[yellow]Retired file-backed write state cleared.[/yellow]")
|
|
68
|
+
if write_gate.status().allowed:
|
|
69
|
+
console.print(
|
|
70
|
+
f"[yellow]{write_gate.ENV_VAR} is enabled in this process; "
|
|
71
|
+
"don't export it in a shared shell.[/yellow]"
|
|
72
|
+
)
|
publinker_cli/config.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Static Auth0 + API config for publinker-cli.
|
|
2
|
+
|
|
3
|
+
These values are non-secret per OAuth spec (the audience and client_id of a public
|
|
4
|
+
Native client are public), so they live in source.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
AUTH0_DOMAIN = "dev-hqiwr-re.us.auth0.com"
|
|
12
|
+
# Internal by default; the customer wheel replaces this and the vault namespace.
|
|
13
|
+
AUTH0_CLIENT_ID = "t1NcBeA9ULAC34DYz3Ms5rwIlrFkjW7Z"
|
|
14
|
+
AUTH0_AUDIENCE = "https://backend.publinker.com"
|
|
15
|
+
AUTH0_SCOPES = "openid profile email offline_access"
|
|
16
|
+
|
|
17
|
+
# Per-stage API base URLs.
|
|
18
|
+
STAGE_URLS: dict[str, str] = {
|
|
19
|
+
"prod": "https://hlkl4tw3vd.execute-api.us-east-1.amazonaws.com/prod",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
DEFAULT_STAGE = "prod"
|
|
23
|
+
|
|
24
|
+
# Keyring service name — namespaces our tokens away from other apps.
|
|
25
|
+
KEYRING_SERVICE = "publinker-cli-customer"
|
|
26
|
+
KEYRING_ACCOUNT = "default"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class Auth0Config:
|
|
31
|
+
domain: str
|
|
32
|
+
client_id: str
|
|
33
|
+
audience: str
|
|
34
|
+
scopes: str
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def device_code_url(self) -> str:
|
|
38
|
+
return f"https://{self.domain}/oauth/device/code"
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def token_url(self) -> str:
|
|
42
|
+
return f"https://{self.domain}/oauth/token"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def auth0_config() -> Auth0Config:
|
|
46
|
+
return Auth0Config(
|
|
47
|
+
domain=AUTH0_DOMAIN,
|
|
48
|
+
client_id=AUTH0_CLIENT_ID,
|
|
49
|
+
audience=AUTH0_AUDIENCE,
|
|
50
|
+
scopes=AUTH0_SCOPES,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def available_stages() -> list[str]:
|
|
55
|
+
"""Stages this build may call. Customer builds get prod only (DEV-1566)."""
|
|
56
|
+
from publinker_cli.audience import is_customer_build
|
|
57
|
+
|
|
58
|
+
return [DEFAULT_STAGE] if is_customer_build() else sorted(STAGE_URLS)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def api_base_url(stage: str) -> str:
|
|
62
|
+
if stage not in available_stages():
|
|
63
|
+
raise ValueError(f"unknown stage {stage!r}; valid: {available_stages()}")
|
|
64
|
+
return STAGE_URLS[stage]
|