mirror of
https://github.com/Tencent/WeKnora.git
synced 2026-06-04 13:30:32 +08:00
refactor(cli): finish context→profile cascade + post-review hardening (BREAKING)
Post-review polish on the v0.7 wire / surface contract. Bundles five
follow-ups that landed after the main BREAKING feat commit:
1. Complete context→profile cascade (internal API + YAML schema)
The prior commit renamed only the user-visible surface (commands /
flags / env / project link / envelope field). The internal Go API
and on-disk config schema were still half-renamed — an L-25
self-consistency violation flagged by post-merge review. Closed here:
Internal Go API:
- config.Context → config.Profile
- config.Config.CurrentContext → CurrentProfile
- config.Config.Contexts → Profiles
- LoginOptions.Context → LoginOptions.Profile
- clearContextSecrets() → clearProfileSecrets()
- saveContextRef() → saveProfileRef()
- secrets.Store: param name `context` → `profile` (interface +
FileStore + KeyringStore + MemStore)
- cmdutil.LoadSecret(store, context, key) → LoadSecret(store, profile, key)
- cmdutil.RefreshAndPersist's ctxName → profileName
- Local var `ctx := &config.Profile{...}` → `prof := &config.Profile{...}`
in auth/login.go to eliminate the visual collision with Go stdlib
context.Context that motivated the whole rename in the first place.
On-disk config.yaml schema:
- current_context: → current_profile:
- contexts: → profiles:
- Pre-1.0 break, no compat alias. Users on v0.6 dogfooded configs
must delete ~/.config/weknora/config.yaml or hand-rename the two
keys (CHANGELOG migration note added).
Tests / fixtures / golden files:
- factory_test.go YAML fixture + assertion updated.
- acceptance/e2e/e2e_test.go writeContextYAML → writeProfileYAML,
fixture YAML keys updated.
- acceptance/testdata/wire/doctor.error_network.json golden updated
("active context" → "active profile" in hint string).
User-visible prose sweep:
- cmd/mcp/serve.go --help Long: "active context (or --context)" →
"active profile (or --profile)" — most-visible miss.
- cmd/{kb/list, search/kb, session/list, api/api} Short/Long help.
- cmd/auth/login.go stdout: `(context=%s)` → `(profile=%s)`.
- cmd/auth/logout.go error: `"no current context"` → `"no current profile"`.
- cmd/doctor/doctor.go hint string (also the wire golden above).
- cmd/auth/refresh.go error: `"refresh token missing for context"` →
`"refresh token missing for profile"`.
- README.md: `## Multi-context` H2 → `## Multi-profile`; code-block
comment `# current context` → `# current profile`.
Code-comment / docstring sweep across cli/cmd/auth/ and
cli/internal/cmdutil/. Comments referencing Go stdlib context.Context,
the RAG / LLM "context window" concept, and historical CHANGELOG
entries for v0.4 / v0.5 were left alone.
CHANGELOG v0.7 BREAKING entry gains the on-disk-schema bullet under
the existing "context → profile" item.
2. Profile name validation (shell-injection guard)
`envelope.error.retry_command` is a single shell-string field. An
AI agent that exec()s it via `sh -c <retry_command>` was injectable
through a maliciously-named profile:
weknora auth logout --name 'x; rm -rf ~'
# would produce: retry_command = "weknora auth logout --name x; rm -rf ~ -y"
`cmd/profile/add.go` already enforced an alphanumeric + `-_.`
allowlist via `validateName`. The `auth login` and `auth logout`
paths bypassed it.
- Moved validation from `cmd/profile/add.go` to
`cli/internal/cmdutil/profilename.go` as exported
`ValidateProfileName` (cmdutil is the import-cycle-safe home;
internal/config can't depend on cmdutil).
- `auth login` runs the validator before any persist call.
- `auth logout` runs the validator on `opts.Name` before
constructing `retry_command`.
- Unit tests (`profilename_test.go`) cover the allowlist, empty
rejection, path-traversal, shell metacharacters (`;`, `&`, `|`,
`$()`, backticks, quotes, whitespace, glob, redirects), and the
user-facing hint text. The shell-metachar test exists as a
regression guard.
Wire shape (`retry_command` string → `retry_command_argv []string`)
remains a v0.8 additive change per ROADMAP — this fix removes the
practical exploit path without touching the wire contract.
3. AI-agent terminology disambiguation
"agent" has three referents in this codebase: (a) WeKnora's
server-side Custom Agent resource, (b) the removed `agent invoke`
verb, (c) external LLM/automation consumers. Per project memory
feedback_no_meta_disambiguation_in_docs, the fix is full-term
naming, not "X has N meanings" prose. Surgical changes at section
headers + ambiguous prose:
- AGENTS.md: "Agent decision shortcuts" → "AI agent decision
shortcuts"; "agent-callable surface" → "AI-agent-callable
surface".
- README.md: "Designed to be agent-first" → "AI-agent-first";
"Other agent ergonomics" → "Other AI-agent ergonomics"; "in
agent contexts" → "in AI-agent contexts"; "for CI / agents" →
"for CI / AI agents".
Anaphoric "agents" inside paragraphs that already established
"AI agents" was left alone — full substitution everywhere would
have been prose noise without clarity gain.
4. Wire-contract review follow-ups
Real findings from a second-pass review of the v0.7 envelope /
streaming / surface design. Per project memory
feedback_check_in_domain_anchor_first, candidate findings were
first verified against the in-domain peer CLI explicitly cited as
the envelope anchor; two earlier-flagged issues turned out to be
in-pattern and were withdrawn.
Surviving fixes:
- AGENTS.md success-envelope example rewritten. The prior example
showed `has_more: false` / `_notice: {}` as if they were always
present, but both fields are `omitempty` and never serialize
when zero / nil. Replaced with three realistic shapes (list /
single resource / mutation with no payload) and added a note
that optional fields are omitted when empty.
- cmd/chat/chat.go Args: MinimumNArgs(1) → ExactArgs(1).
v0.6 silently joined `weknora chat hello world` into
`"hello world"`. v0.7 now rejects multi-arg with exit 2,
matching `weknora session ask`. BREAKING; CHANGELOG entry
added under v0.7 BREAKING.
- internal/output/envelope.go extracts NewEnvelope(data, meta,
profile) constructor. The jq-filter path in
cmdutil.FormatOptions.Emit was manually rebuilding the
envelope literal alongside the canonical WriteEnvelope path —
drift risk when fields are added. Single construction point now.
- internal/cmdutil/factory.go adds AddKBFlag(cmd) helper.
Five files (chat, doc/list, doc/upload, doc/create, doc/fetch)
had verbatim-identical `cmd.Flags().String("kb", ...)`
declarations. Centralised so flag name + help text stay
in sync with Factory.ResolveKB. Docstring reordering + gofmt
fixup landed in the same edit to keep ResolveKB's own godoc
attached to its function.
5. OSS-readiness comment / doc sweep
Pre-publication scrub of code, comments, and shipped Markdown to
remove references that only make sense in the development repo:
- AGENTS.md "Deliberate deviations + mainstream alignments"
section: removed peer-project name-drops from the comparison
table; rewrote as five flagged design decisions with rationale
but no specific competitor named. The four rows that previously
contrasted against a named peer CLI now state WeKnora's choice
+ rationale directly. Section header renamed to "Design
decisions worth flagging" since it is no longer a
deviation/alignment matrix.
- CHANGELOG v0.7 BREAKING rationales: three references to a
named peer CLI removed; the context→profile rationale now
cites only mainstream multi-credential CLIs by category (AWS /
Stripe / OpenAI / Anthropic), and the `api -d/--data` removal
rationale cites only `gh api` / `curl`. `chat` BREAKING entry
rationale similarly simplified.
- 35 cross-references to design-spec section numbers (§4.1 /
§4.5 / §5.3 etc.) removed from Go doc comments and test
comments across 13 files. The referenced spec lives outside
the shipped tree; readers of the public repo cannot resolve
them. Each reference replaced with a self-contained semantic
description (e.g. "the batch envelope" / "AGENTS.md section
on the success path").
- Mixed-language strings translated to English:
- Four Go comments: internal/cmdutil/exit.go:213,215,
internal/cmdutil/errors.go:156,
internal/output/batch_test.go:90,
internal/output/envelope_test.go:27.
- One CHANGELOG section title:
`v0.7 — Agent-first wire contract + 命令面集中清理` →
`... + command-surface cleanup`.
- CJK test fixtures (internal/text/truncate_test.go CJK
truncation cases, cmd/session/list_test.go Chinese session
title, acceptance/e2e/e2e_test.go Chinese RAG corpus)
retained — they are intentional test inputs, not stray prose.
- Makefile help comment: `golangci-lint added in PR-9` →
`golangci-lint planned`. Internal PR numbering should not
surface in shipped Makefile prose.
Build green, 28/28 packages, +5 new ValidateProfileName tests.
go vet / gofmt / go mod verify / go mod tidy all clean.
Rationale for the cascade: pre-1.0 is the cheapest moment to close
L-25 self-consistency (L-26). The half-finished internal rename
would have perpetuated the very `context` vs `context.Context`
ambiguity that motivated v0.7's user-visible rename in the first
place.
This commit is contained in:
@@ -12,25 +12,37 @@ JSON field you write becomes part of an agent's decision-making input.**
|
||||
|
||||
### Stdout (success path)
|
||||
|
||||
All `--format json` (default) commands emit a symmetric envelope:
|
||||
All `--format json` (default) commands emit a symmetric envelope. Optional
|
||||
fields are `omitempty` — they only appear when populated:
|
||||
|
||||
```json
|
||||
// list (kb list, doc list, ...) — data is an array, meta carries count
|
||||
{
|
||||
"ok": true,
|
||||
"data": "<T>",
|
||||
"meta": {"count": 0, "has_more": false},
|
||||
"_notice": {},
|
||||
"profile": "default"
|
||||
"data": [ {"id": "kb_abc", "name": "prod"} ],
|
||||
"meta": {"count": 1},
|
||||
"profile": "prod"
|
||||
}
|
||||
|
||||
// single resource (kb view, doc view, ...) — data is an object
|
||||
{
|
||||
"ok": true,
|
||||
"data": {"id": "kb_abc", "name": "prod", "description": "..."},
|
||||
"profile": "prod"
|
||||
}
|
||||
|
||||
// mutation success with no payload (some delete / edit paths)
|
||||
{"ok": true, "profile": "prod"}
|
||||
```
|
||||
|
||||
`data` is omitted on mutation-only success (no payload). `meta` carries list
|
||||
counters (`count`, `has_more`) and batch successes/failures. `meta.next_cursor`,
|
||||
`meta.total_count`, and `meta.request_id` are reserved — not currently
|
||||
populated; planned for v0.8 when the SDK exposes pagination cursors and response
|
||||
headers. `_notice` is reserved — open-map infrastructure is in place for
|
||||
deprecation / version_skew / security notices; producer wiring planned for v0.8.
|
||||
`profile` echoes the resolved profile name.
|
||||
counters (`count`, `has_more`) and batch successes/failures, and is omitted
|
||||
when empty. `meta.next_cursor`, `meta.total_count`, and `meta.request_id` are
|
||||
reserved — not currently populated; planned for v0.8 when the SDK exposes
|
||||
pagination cursors and response headers. `_notice` is reserved — open-map
|
||||
infrastructure is in place for deprecation / version_skew / security notices;
|
||||
the field is omitted until a producer is wired in v0.8. `profile` echoes the
|
||||
resolved profile name and is omitted when no profile is configured.
|
||||
|
||||
### Stderr (error path)
|
||||
|
||||
@@ -126,42 +138,39 @@ Make errors structured, actionable, and specific.
|
||||
> subcommand (`weknora version --format json`) or `WEKNORA_AGENT_HELP=1
|
||||
> weknora <cmd> --help`. Planned fix for v0.8.
|
||||
|
||||
## Deliberate deviations + mainstream alignments
|
||||
## Design decisions worth flagging
|
||||
|
||||
Five design decisions: four deliberate deviations from mainstream CLI
|
||||
conventions, one mainstream alignment.
|
||||
Five design decisions readers may want context on: where WeKnora picks an
|
||||
opinionated default, what the trade-off is, and what mainstream practice it
|
||||
is or isn't aligned with.
|
||||
|
||||
### 1. Channel split: success → stdout, error → stderr
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Mainstream** | lark CLI puts both success and error envelopes on stdout |
|
||||
| **WeKnora** | success envelope → stdout; error envelope → stderr |
|
||||
| **Rationale** | `weknora ... --format json \| jq '.data[]'` must not mix error objects into the data stream. Channel split lets pipeline consumers suppress errors with `2>/dev/null` and still get clean JSON on stdout. (kubectl follows this; gh does not.) |
|
||||
| **Rationale** | `weknora ... --format json \| jq '.data[]'` must not mix error objects into the data stream. Channel split lets pipeline consumers suppress errors with `2>/dev/null` and still get clean JSON on stdout. (kubectl follows this convention.) |
|
||||
|
||||
### 2. `weknora api DELETE` triggers exit-10 confirmation
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Mainstream** | gh `gh api` / lark / Stripe CLI raw-API commands don't prompt confirm; they rely on restricted API keys |
|
||||
| **WeKnora** | DELETE triggers exit-10 (`input.confirmation_required`); user bypasses with `-y/--yes` |
|
||||
| **Rationale** | DELETE is irreversible; self-hosted deployments may not have restricted credential infrastructure. Defensive default because agents are common consumers. |
|
||||
| **Rationale** | DELETE is irreversible. Most raw-API CLI commands rely on restricted credentials for safety, but self-hosted deployments may not have restricted-credential infrastructure available. Defensive default because agents are common consumers. |
|
||||
|
||||
### 3. `retry_command` distinct from `hint`
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Mainstream** | lark embeds fix commands inside prose hint strings |
|
||||
| **WeKnora** | two separate fields: `retry_command` (suggested next argv, directly-executable for non-destructive errors; informational only on exit-10) + `hint` (prose) |
|
||||
| **Rationale** | Agents don't regex-extract argv from prose — known fragility. Trade-off: one extra envelope field. On exit-10, the user must approve the destructive write; agents surface `retry_command` for human review, not auto-execution. |
|
||||
|
||||
### 4. NDJSON event stream has no envelope wrapping — mainstream alignment
|
||||
### 4. NDJSON event stream has no envelope wrapping
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Mainstream** | 4/4 CLIs with NDJSON event streams (Claude Code / Codex / Gemini / lark webhook) use bare `{type:...}` — zero use envelope wrapping |
|
||||
| **WeKnora** | same as mainstream |
|
||||
| **Rationale** | Streaming envelope requires unwrap before dispatch — net burden with no benefit. This is not a deviation; it is explicit alignment with the surveyed field. |
|
||||
| **WeKnora** | streaming commands (`chat`, `session ask`) emit bare `{type:...}` per line; no envelope |
|
||||
| **Rationale** | This matches established practice across NDJSON-emitting CLIs and webhook protocols. A streaming envelope requires unwrap before dispatch — net burden with no benefit. |
|
||||
|
||||
### 5. No `schema_version` field in payload
|
||||
|
||||
@@ -409,9 +418,9 @@ Agents parse the first colon to extract the typed code. The exit code class (see
|
||||
|
||||
<!-- ERROR_REFERENCE_END -->
|
||||
|
||||
### Agent decision shortcuts
|
||||
### AI agent decision shortcuts
|
||||
|
||||
For common retry patterns, agents can hardcode:
|
||||
For common retry patterns, AI agents can hardcode:
|
||||
|
||||
- `network.*` → retry with exponential backoff
|
||||
- `auth.token_expired` → run `weknora auth refresh`, then retry once
|
||||
@@ -468,7 +477,7 @@ The curated 10 tools (`cli/internal/mcp/tools.go`):
|
||||
| `agent_list` | list custom agents |
|
||||
| `agent_invoke` | run a query through a custom agent |
|
||||
|
||||
Adding a tool is a deliberate API expansion — the agent-callable surface is the reason this CLI ships an MCP server, not its CLI command list, so the registration list in `registerTools` is maintained by hand.
|
||||
Adding a tool is a deliberate API expansion — the AI-agent-callable surface is the reason this CLI ships an MCP server, not its CLI command list, so the registration list in `registerTools` is maintained by hand.
|
||||
|
||||
## Command surface design SOP
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ CLI history before v0.3 is recorded in the project root
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### v0.7 — Agent-first wire contract + 命令面集中清理
|
||||
### v0.7 — Agent-first wire contract + command-surface cleanup
|
||||
|
||||
#### BREAKING (v0.6 → v0.7)
|
||||
- **All JSON output now wrapped in symmetric envelope.**
|
||||
@@ -44,14 +44,17 @@ CLI history before v0.3 is recorded in the project root
|
||||
- **`weknora context` command group renamed to `weknora profile`.**
|
||||
- Subcommands `context list/add/remove/use` → `profile list/add/remove/use`.
|
||||
- Global flag `--context` → `--profile`.
|
||||
- On-disk config `~/.config/weknora/config.yaml` keys `current_context:` /
|
||||
`contexts:` → `current_profile:` / `profiles:` (no backwards-compat
|
||||
alias; delete the file or rename the keys by hand to migrate).
|
||||
- Binding file `.weknora/project.yaml` field `context:` → `profile:`
|
||||
(no backwards-compat alias; re-run `weknora link` to regenerate).
|
||||
(re-run `weknora link` to regenerate).
|
||||
- `profile use` JSON fields `current_context` / `previous_context` →
|
||||
`current_profile` / `previous_profile`.
|
||||
- `weknora link` JSON field `context` → `profile`.
|
||||
- Rationale: `context` collided with LLM "context window" / RAG "context" /
|
||||
Go `context.Context`; mainstream multi-credential CLIs (AWS / Stripe /
|
||||
OpenAI / Anthropic / lark) all use `profile`.
|
||||
Go `context.Context`. Mainstream multi-credential CLIs (AWS, Stripe,
|
||||
OpenAI, Anthropic) settle on `profile` as the term of art.
|
||||
- **`weknora agent invoke` removed; use `weknora session ask --agent <id>`.**
|
||||
- Server route is `POST /sessions/{session_id}/agent-qa` — session-anchored.
|
||||
- `weknora agent` keeps CRUD only (list / view / create / edit / delete /
|
||||
@@ -73,7 +76,7 @@ CLI history before v0.3 is recorded in the project root
|
||||
- **`weknora api -d/--data` flag removed; use `--input <file>` or `--input -`
|
||||
(stdin).**
|
||||
- `weknora api` now accepts any non-empty HTTP method (whitelist removed;
|
||||
aligns with gh / lark / curl).
|
||||
matches `gh api` / `curl` behaviour).
|
||||
- Migration: `weknora api -d '{"foo":1}' /endpoint` →
|
||||
`echo '{"foo":1}' | weknora api --input - /endpoint`.
|
||||
- **Batch operations envelope shape — per-item `ok` pattern.**
|
||||
@@ -91,6 +94,11 @@ CLI history before v0.3 is recorded in the project root
|
||||
- `input.unknown_subcommand` with `detail.{unknown, command_path, available[]}`
|
||||
+ `retry_command: "<parent> --help"`. Replaces v0.6's free-form
|
||||
`"unknown command \"x\" for \"weknora\""` prose.
|
||||
- **`weknora chat` requires the query as a single quoted argument.**
|
||||
- v0.6: `MinimumNArgs(1)` silently joined `weknora chat hello world` into
|
||||
`"hello world"`.
|
||||
- v0.7: `ExactArgs(1)` rejects multi-arg with exit 2; matches
|
||||
`weknora session ask`. Quote the query: `weknora chat "hello world"`.
|
||||
|
||||
#### Added
|
||||
- **`WEKNORA_PROFILE` env var** selects the active profile for a single
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# make build compile to ./bin/weknora with version metadata
|
||||
# make test go test ./...
|
||||
# make test-coverage same with coverage report
|
||||
# make lint go vet (golangci-lint added in PR-9)
|
||||
# make lint go vet (golangci-lint planned)
|
||||
# make tidy go mod tidy
|
||||
# make clean remove ./bin
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ release. Grab the latest from the [Releases page](https://github.com/Tencent/WeK
|
||||
# 1. Log in to your WeKnora server (interactive password prompt)
|
||||
weknora auth login --host https://kb.example.com
|
||||
|
||||
# 2. Or pipe an API key from stdin (for CI / agents)
|
||||
# 2. Or pipe an API key from stdin (for CI / AI agents)
|
||||
echo "sk-..." | weknora auth login --host https://kb.example.com --with-token
|
||||
|
||||
# 3. List knowledge bases
|
||||
@@ -116,7 +116,7 @@ For AI agents (Claude Code, Cursor, Gemini CLI, etc.) integrating WeKnora:
|
||||
|
||||
---
|
||||
|
||||
## Multi-context
|
||||
## Multi-profile
|
||||
|
||||
Switch between several WeKnora servers (or several tenants on the same server)
|
||||
without re-logging in:
|
||||
@@ -136,7 +136,7 @@ under `$XDG_CONFIG_HOME/weknora/secrets/`. The active profile lives in
|
||||
To remove a profile's stored credentials:
|
||||
|
||||
```bash
|
||||
weknora auth logout # current context
|
||||
weknora auth logout # current profile
|
||||
weknora auth logout --name staging # specific
|
||||
weknora auth logout --all
|
||||
```
|
||||
@@ -145,7 +145,7 @@ weknora auth logout --all
|
||||
|
||||
## Wire contract
|
||||
|
||||
Designed to be agent-first. Stable across minor releases; breaking
|
||||
Designed to be AI-agent-first. Stable across minor releases; breaking
|
||||
changes announced in the changelog and the corresponding
|
||||
`weknora --version` bump.
|
||||
|
||||
@@ -220,9 +220,9 @@ explicit confirmation". Pass `-y/--yes` on `kb delete` /
|
||||
**Never auto-add `-y` without the user's explicit go-ahead** — exit 10
|
||||
is the guard against unintended writes.
|
||||
|
||||
### Other agent ergonomics
|
||||
### Other AI-agent ergonomics
|
||||
|
||||
- For chat / session ask in agent contexts, pass `--format json` —
|
||||
- For chat / session ask in AI-agent contexts, pass `--format json` —
|
||||
streaming tokens to stdout makes JSON parsing impossible.
|
||||
- `--format json` composes with the global `--profile <name>` for
|
||||
single-shot profile overrides without disk writes.
|
||||
|
||||
@@ -49,7 +49,7 @@ import (
|
||||
// server - mock /api/v1/* endpoints; nil means no network.
|
||||
// preConfig - seed config.yaml under the per-test XDG_CONFIG_HOME
|
||||
// (set by newTestFactory); use for cases like
|
||||
// `context use` that read local state without an
|
||||
// `profile use` that read local state without an
|
||||
// SDK round-trip.
|
||||
// wantErr - non-zero exit expected.
|
||||
// wantStderrSubstring - stderr must contain this substring (typically the
|
||||
@@ -130,8 +130,8 @@ var wireCases = []wireCase{
|
||||
args: []string{"profile", "use", "production", "--format", "json"},
|
||||
preConfig: func(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
CurrentContext: "staging",
|
||||
Contexts: map[string]config.Context{
|
||||
CurrentProfile: "staging",
|
||||
Profiles: map[string]config.Profile{
|
||||
"staging": {Host: "https://staging.example.com"},
|
||||
"production": {Host: "https://prod.example.com"},
|
||||
},
|
||||
|
||||
@@ -29,7 +29,7 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestRAGFullLoop walks the demo MVP path: link a context, create a KB,
|
||||
// TestRAGFullLoop walks the demo MVP path: link a profile, create a KB,
|
||||
// upload a doc, wait for indexing, search it, then chat against it. Each
|
||||
// step parses the CLI's bare JSON to extract IDs for the next step -
|
||||
// validating both functional behavior and wire-contract stability.
|
||||
@@ -40,7 +40,7 @@ func TestRAGFullLoop(t *testing.T) {
|
||||
|
||||
bin := buildBinary(t)
|
||||
xdg := t.TempDir()
|
||||
writeContextYAML(t, xdg, host, token)
|
||||
writeProfileYAML(t, xdg, host, token)
|
||||
|
||||
env := append(os.Environ(),
|
||||
"XDG_CONFIG_HOME="+xdg,
|
||||
@@ -142,18 +142,18 @@ func buildBinary(t *testing.T) string {
|
||||
return out
|
||||
}
|
||||
|
||||
// writeContextYAML drops a minimal config.yaml into XDG_CONFIG_HOME so the
|
||||
// CLI finds a context without needing `weknora context add` (which prompts
|
||||
// writeProfileYAML drops a minimal config.yaml into XDG_CONFIG_HOME so the
|
||||
// CLI finds a profile without needing `weknora profile add` (which prompts
|
||||
// in interactive scenarios). Tests using `auth login` belong to a different
|
||||
// suite; here we go straight to authenticated calls.
|
||||
func writeContextYAML(t *testing.T, xdg, host, token string) {
|
||||
func writeProfileYAML(t *testing.T, xdg, host, token string) {
|
||||
t.Helper()
|
||||
dir := filepath.Join(xdg, "weknora")
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
t.Fatalf("mkdir xdg: %v", err)
|
||||
}
|
||||
yaml := fmt.Sprintf(`current_context: e2e
|
||||
contexts:
|
||||
yaml := fmt.Sprintf(`current_profile: e2e
|
||||
profiles:
|
||||
- name: e2e
|
||||
host: %s
|
||||
token: %s
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"ok":true,"data":{"summary":{"all_passed":false,"passed":1,"failed":1,"skipped":2},"checks":[{"name":"base_url_reachable","status":"fail","details":"server returned 500","hint":"verify the host configured for the active context (run `weknora auth login --host=...`) and network reachability"},{"name":"auth_credential","status":"skip","details":"prereq failed: base_url_reachable"},{"name":"server_version","status":"skip","details":"prereq failed: auth_credential"},{"name":"credential_storage","status":"ok","details":"keyring or file storage available"}]}}
|
||||
{"ok":true,"data":{"summary":{"all_passed":false,"passed":1,"failed":1,"skipped":2},"checks":[{"name":"base_url_reachable","status":"fail","details":"server returned 500","hint":"verify the host configured for the active profile (run `weknora auth login --host=...`) and network reachability"},{"name":"auth_credential","status":"skip","details":"prereq failed: base_url_reachable"},{"name":"server_version","status":"skip","details":"prereq failed: auth_credential"},{"name":"credential_storage","status":"ok","details":"keyring or file storage available"}]}}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
// Package api implements the `weknora api` raw HTTP passthrough command.
|
||||
//
|
||||
// Shape: one positional (path) + `-X/--method` flag, default GET (auto-
|
||||
// promoted to POST when a body is supplied via --data or --input). The two
|
||||
// body-source flags are mutually exclusive. Default raw response body to
|
||||
// stdout; --format json emits a {status, headers, body} object. Reuses sdk.Client.Raw which already
|
||||
// applies tenant + auth headers.
|
||||
// promoted to POST when a body is supplied via --input). Default raw
|
||||
// response body to stdout; --format json emits a {status, headers, body}
|
||||
// object. Reuses sdk.Client.Raw which already applies tenant + auth headers.
|
||||
package api
|
||||
|
||||
import (
|
||||
@@ -58,7 +57,7 @@ The default method is GET; passing --input auto-promotes it to POST. Use
|
||||
PATCH / HEAD / OPTIONS / TRACE / custom).
|
||||
|
||||
Auth, tenant, and request-id headers are applied automatically from the
|
||||
active context. The response body is written to stdout by default; use
|
||||
active profile. The response body is written to stdout by default; use
|
||||
--format json to emit a {status, headers, body} envelope.
|
||||
|
||||
Examples:
|
||||
|
||||
@@ -20,7 +20,7 @@ const (
|
||||
ModeUnknown = "unknown"
|
||||
)
|
||||
|
||||
// modeFromRefs maps the per-context TokenRef / APIKeyRef presence to a
|
||||
// modeFromRefs maps the per-profile TokenRef / APIKeyRef presence to a
|
||||
// canonical credential-mode token. Bearer wins when both are present -
|
||||
// matches the precedence in cmdutil.buildClient.
|
||||
func modeFromRefs(apiKeyRef, tokenRef string) string {
|
||||
|
||||
@@ -74,17 +74,17 @@ func TestPersistAPIKey_WritesContext(t *testing.T) {
|
||||
}
|
||||
opts := &LoginOptions{
|
||||
Host: "https://kb.example.com",
|
||||
Context: "ci",
|
||||
Profile: "ci",
|
||||
APIKey: "sk-zzz",
|
||||
}
|
||||
require.NoError(t, persistAPIKey(opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, nil))
|
||||
v, _ := store.Get("ci", "api_key")
|
||||
assert.Equal(t, "sk-zzz", v)
|
||||
cfg, _ := f.Config()
|
||||
assert.Equal(t, "ci", cfg.CurrentContext)
|
||||
assert.Equal(t, "https://kb.example.com", cfg.Contexts["ci"].Host)
|
||||
assert.Equal(t, "ci", cfg.CurrentProfile)
|
||||
assert.Equal(t, "https://kb.example.com", cfg.Profiles["ci"].Host)
|
||||
// APIKeyRef should be the mem:// URI from the store's Ref method.
|
||||
assert.Equal(t, "mem://ci/api_key", cfg.Contexts["ci"].APIKeyRef)
|
||||
assert.Equal(t, "mem://ci/api_key", cfg.Profiles["ci"].APIKeyRef)
|
||||
}
|
||||
|
||||
func TestPersistJWT_StoresBothTokens(t *testing.T) {
|
||||
@@ -98,7 +98,7 @@ func TestPersistJWT_StoresBothTokens(t *testing.T) {
|
||||
}
|
||||
opts := &LoginOptions{
|
||||
Host: "https://x",
|
||||
Context: "p",
|
||||
Profile: "p",
|
||||
}
|
||||
resp := &sdk.LoginResponse{
|
||||
Token: "jwt-acc",
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
type ListOptions struct{}
|
||||
|
||||
// authListFields enumerates the fields surfaced for `--format json` discovery on
|
||||
// `auth list`. Each entry is a per-context summary row.
|
||||
// `auth list`. Each entry is a per-profile summary row.
|
||||
var authListFields = []string{
|
||||
"name", "host", "user", "mode", "current",
|
||||
}
|
||||
@@ -56,14 +56,14 @@ func runList(fopts *cmdutil.FormatOptions, f *cmdutil.Factory) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entries := make([]listEntry, 0, len(cfg.Contexts))
|
||||
for name, c := range cfg.Contexts {
|
||||
entries := make([]listEntry, 0, len(cfg.Profiles))
|
||||
for name, c := range cfg.Profiles {
|
||||
entries = append(entries, listEntry{
|
||||
Name: name,
|
||||
Host: c.Host,
|
||||
User: c.User,
|
||||
Mode: modeFromRefs(c.APIKeyRef, c.TokenRef),
|
||||
Current: name == cfg.CurrentContext,
|
||||
Current: name == cfg.CurrentProfile,
|
||||
})
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool { return entries[i].Name < entries[j].Name })
|
||||
|
||||
@@ -22,8 +22,8 @@ func newListFactory(cfg *config.Config) *cmdutil.Factory {
|
||||
func TestList_TextRender(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
cfg := &config.Config{
|
||||
CurrentContext: "prod",
|
||||
Contexts: map[string]config.Context{
|
||||
CurrentProfile: "prod",
|
||||
Profiles: map[string]config.Profile{
|
||||
"prod": {Host: "https://prod", User: "alice@example.com", TokenRef: "keychain://prod/access"},
|
||||
"staging": {Host: "https://staging", APIKeyRef: "keychain://staging/api_key"},
|
||||
},
|
||||
@@ -31,7 +31,7 @@ func TestList_TextRender(t *testing.T) {
|
||||
require.NoError(t, runList(&cmdutil.FormatOptions{Mode: cmdutil.FormatText}, newListFactory(cfg)))
|
||||
|
||||
got := out.String()
|
||||
// One row per context, current marked with `*`.
|
||||
// One row per profile, current marked with `*`.
|
||||
assert.Contains(t, got, "* prod")
|
||||
assert.Contains(t, got, " staging")
|
||||
// Mode column.
|
||||
@@ -39,7 +39,7 @@ func TestList_TextRender(t *testing.T) {
|
||||
assert.Contains(t, got, ModeAPIKey)
|
||||
// Sorted alphabetically - prod after staging? No: "prod" < "staging".
|
||||
assert.Less(t, strings.Index(got, "prod"), strings.Index(got, "staging"),
|
||||
"contexts should render sorted by name")
|
||||
"profiles should render sorted by name")
|
||||
}
|
||||
|
||||
func TestList_Empty(t *testing.T) {
|
||||
@@ -51,8 +51,8 @@ func TestList_Empty(t *testing.T) {
|
||||
func TestList_JSON_BareArray(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
cfg := &config.Config{
|
||||
CurrentContext: "prod",
|
||||
Contexts: map[string]config.Context{
|
||||
CurrentProfile: "prod",
|
||||
Profiles: map[string]config.Profile{
|
||||
"prod": {Host: "https://prod", User: "alice", TokenRef: "tok"},
|
||||
"staging": {Host: "https://staging", APIKeyRef: "key"},
|
||||
},
|
||||
@@ -78,7 +78,7 @@ func TestList_JSON_BareArray(t *testing.T) {
|
||||
|
||||
func TestModeFromRefs(t *testing.T) {
|
||||
// Hand-edited config with neither ref set - surface "unknown" rather
|
||||
// than pretending the context is a valid login.
|
||||
// than pretending the profile is a valid login.
|
||||
assert.Equal(t, ModeUnknown, modeFromRefs("", ""))
|
||||
assert.Equal(t, ModeBearer, modeFromRefs("", "tok"))
|
||||
assert.Equal(t, ModeAPIKey, modeFromRefs("key", ""))
|
||||
|
||||
@@ -26,7 +26,7 @@ var authLoginFields = []string{
|
||||
// LoginOptions is the configuration captured from flags + prompts.
|
||||
type LoginOptions struct {
|
||||
Host string // --host
|
||||
Context string // --name: context name to write into config.yaml
|
||||
Profile string // --name: profile name to write into config.yaml
|
||||
WithToken bool // --with-token: read api key from stdin instead of prompting password
|
||||
APIKey string // populated by --with-token from stdin
|
||||
Email string
|
||||
@@ -45,7 +45,7 @@ type LoginService interface {
|
||||
// fails fast at `auth login --with-token` time rather than on the next
|
||||
// authenticated call.
|
||||
//
|
||||
// Returns the resolved user (used to populate context.User / TenantID at
|
||||
// Returns the resolved user (used to populate Profile.User / TenantID at
|
||||
// rest, so later `auth list` reflects who owns the key).
|
||||
type apiKeyValidator func(ctx context.Context, host, apiKey string) (*sdk.AuthUser, error)
|
||||
|
||||
@@ -72,8 +72,8 @@ func NewCmdLogin(f *cmdutil.Factory, runF func(context.Context, *LoginOptions, *
|
||||
Long: `Log in by email + password (interactive prompt) or pipe an API key with --with-token.
|
||||
|
||||
Credentials are persisted to the OS keyring when available; otherwise to a
|
||||
0600 file under $XDG_CONFIG_HOME/weknora/secrets. The named context becomes
|
||||
the current_context in ~/.config/weknora/config.yaml.`,
|
||||
0600 file under $XDG_CONFIG_HOME/weknora/secrets. The named profile becomes
|
||||
the current_profile in ~/.config/weknora/config.yaml.`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
fopts, err := cmdutil.CheckFormatFlag(c)
|
||||
@@ -93,7 +93,7 @@ the current_context in ~/.config/weknora/config.yaml.`,
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&opts.Host, "host", "", "WeKnora server URL, e.g. https://kb.example.com")
|
||||
cmd.Flags().StringVar(&opts.Context, "name", "default", "Profile name to register in config.yaml")
|
||||
cmd.Flags().StringVar(&opts.Profile, "name", "default", "Profile name to register in config.yaml")
|
||||
cmd.Flags().BoolVar(&opts.WithToken, "with-token", false, "Read an API key from stdin instead of prompting for password")
|
||||
cmdutil.AddFormatFlag(cmd, authLoginFields...)
|
||||
_ = cmd.MarkFlagRequired("host")
|
||||
@@ -101,7 +101,7 @@ the current_context in ~/.config/weknora/config.yaml.`,
|
||||
}
|
||||
|
||||
// loginServiceFor returns a fresh SDK client targeting host. login.go cannot
|
||||
// reuse Factory.Client because that closure requires an existing context.
|
||||
// reuse Factory.Client because that closure requires an existing profile.
|
||||
func loginServiceFor(host string) LoginService {
|
||||
if host == "" {
|
||||
return nil
|
||||
@@ -113,6 +113,12 @@ func runLogin(ctx context.Context, opts *LoginOptions, fopts *cmdutil.FormatOpti
|
||||
if err := validateHost(opts.Host); err != nil {
|
||||
return err
|
||||
}
|
||||
// Reject shell-metacharacter / path-like names up-front so opts.Profile
|
||||
// stays safe to interpolate into the keyring namespace, config.yaml
|
||||
// keys, and (later) envelope.error.retry_command. Matches `profile add`.
|
||||
if err := cmdutil.ValidateProfileName(opts.Profile); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if opts.WithToken {
|
||||
key, err := readStdinTrimmed(opts.StdinReader)
|
||||
@@ -179,9 +185,9 @@ func runLogin(ctx context.Context, opts *LoginOptions, fopts *cmdutil.FormatOpti
|
||||
return persistJWT(opts, fopts, f, resp)
|
||||
}
|
||||
|
||||
// persistAPIKey saves the --with-token API key and writes the context.
|
||||
// persistAPIKey saves the --with-token API key and writes the profile.
|
||||
// user is the principal returned by /auth/me during pre-persist validation,
|
||||
// used to populate context.User / TenantID so `auth list` reflects who
|
||||
// used to populate Profile.User / TenantID so `auth list` reflects who
|
||||
// owns the key.
|
||||
func persistAPIKey(opts *LoginOptions, fopts *cmdutil.FormatOptions, f *cmdutil.Factory, user *sdk.AuthUser) error {
|
||||
store, err := f.Secrets()
|
||||
@@ -189,45 +195,45 @@ func persistAPIKey(opts *LoginOptions, fopts *cmdutil.FormatOptions, f *cmdutil.
|
||||
return err
|
||||
}
|
||||
warnOnFileFallback(store)
|
||||
if err := store.Set(opts.Context, "api_key", opts.APIKey); err != nil {
|
||||
if err := store.Set(opts.Profile, "api_key", opts.APIKey); err != nil {
|
||||
return cmdutil.Wrapf(cmdutil.CodeLocalKeychainDenied, err, "save api key")
|
||||
}
|
||||
ctx := &config.Context{
|
||||
prof := &config.Profile{
|
||||
Host: opts.Host,
|
||||
APIKeyRef: store.Ref(opts.Context, "api_key"),
|
||||
APIKeyRef: store.Ref(opts.Profile, "api_key"),
|
||||
}
|
||||
if user != nil {
|
||||
ctx.User = user.Email
|
||||
ctx.TenantID = user.TenantID
|
||||
prof.User = user.Email
|
||||
prof.TenantID = user.TenantID
|
||||
}
|
||||
return saveContextRef(opts, fopts, f, ctx, user)
|
||||
return saveProfileRef(opts, fopts, f, prof, user)
|
||||
}
|
||||
|
||||
// persistJWT saves access + refresh tokens and writes the context.
|
||||
// persistJWT saves access + refresh tokens and writes the profile.
|
||||
func persistJWT(opts *LoginOptions, fopts *cmdutil.FormatOptions, f *cmdutil.Factory, resp *sdk.LoginResponse) error {
|
||||
store, err := f.Secrets()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
warnOnFileFallback(store)
|
||||
if err := store.Set(opts.Context, "access", resp.Token); err != nil {
|
||||
if err := store.Set(opts.Profile, "access", resp.Token); err != nil {
|
||||
return cmdutil.Wrapf(cmdutil.CodeLocalKeychainDenied, err, "save access token")
|
||||
}
|
||||
if resp.RefreshToken != "" {
|
||||
if err := store.Set(opts.Context, "refresh", resp.RefreshToken); err != nil {
|
||||
if err := store.Set(opts.Profile, "refresh", resp.RefreshToken); err != nil {
|
||||
return cmdutil.Wrapf(cmdutil.CodeLocalKeychainDenied, err, "save refresh token")
|
||||
}
|
||||
}
|
||||
c := &config.Context{
|
||||
prof := &config.Profile{
|
||||
Host: opts.Host,
|
||||
TokenRef: store.Ref(opts.Context, "access"),
|
||||
RefreshRef: store.Ref(opts.Context, "refresh"),
|
||||
TokenRef: store.Ref(opts.Profile, "access"),
|
||||
RefreshRef: store.Ref(opts.Profile, "refresh"),
|
||||
}
|
||||
if resp.User != nil {
|
||||
c.User = resp.User.Email
|
||||
c.TenantID = resp.User.TenantID
|
||||
prof.User = resp.User.Email
|
||||
prof.TenantID = resp.User.TenantID
|
||||
}
|
||||
return saveContextRef(opts, fopts, f, c, resp.User)
|
||||
return saveProfileRef(opts, fopts, f, prof, resp.User)
|
||||
}
|
||||
|
||||
// loginResult is the typed payload emitted by `--format json`. mode is derived from
|
||||
@@ -240,22 +246,22 @@ type loginResult struct {
|
||||
TenantID uint64 `json:"tenant_id,omitempty"`
|
||||
}
|
||||
|
||||
// saveContextRef writes the context to config.yaml and prints success.
|
||||
func saveContextRef(opts *LoginOptions, fopts *cmdutil.FormatOptions, f *cmdutil.Factory, ctx *config.Context, user *sdk.AuthUser) error {
|
||||
// saveProfileRef writes the profile to config.yaml and prints success.
|
||||
func saveProfileRef(opts *LoginOptions, fopts *cmdutil.FormatOptions, f *cmdutil.Factory, prof *config.Profile, user *sdk.AuthUser) error {
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.Contexts == nil {
|
||||
cfg.Contexts = map[string]config.Context{}
|
||||
if cfg.Profiles == nil {
|
||||
cfg.Profiles = map[string]config.Profile{}
|
||||
}
|
||||
cfg.Contexts[opts.Context] = *ctx
|
||||
cfg.CurrentContext = opts.Context
|
||||
cfg.Profiles[opts.Profile] = *prof
|
||||
cfg.CurrentProfile = opts.Profile
|
||||
if err := config.Save(cfg); err != nil {
|
||||
return cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "save config")
|
||||
}
|
||||
if fopts.WantsJSON() {
|
||||
result := loginResult{Profile: opts.Context, Host: opts.Host, Mode: ModeAPIKey}
|
||||
result := loginResult{Profile: opts.Profile, Host: opts.Host, Mode: ModeAPIKey}
|
||||
if user != nil {
|
||||
result.Mode = ModeBearer
|
||||
result.User = user.Email
|
||||
@@ -263,11 +269,11 @@ func saveContextRef(opts *LoginOptions, fopts *cmdutil.FormatOptions, f *cmdutil
|
||||
}
|
||||
return fopts.Emit(iostreams.IO.Out, result, nil)
|
||||
}
|
||||
who := opts.Context
|
||||
who := opts.Profile
|
||||
if user != nil {
|
||||
who = user.Email
|
||||
}
|
||||
fmt.Fprintf(iostreams.IO.Out, "✓ Logged in to %s as %s (context=%s)\n", opts.Host, who, opts.Context)
|
||||
fmt.Fprintf(iostreams.IO.Out, "✓ Logged in to %s as %s (profile=%s)\n", opts.Host, who, opts.Profile)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ func TestRunLogin_PasswordMode(t *testing.T) {
|
||||
}}
|
||||
opts := &LoginOptions{
|
||||
Host: "https://kb.example.com",
|
||||
Context: "prod",
|
||||
Profile: "prod",
|
||||
}
|
||||
require.NoError(t, runLogin(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, svc))
|
||||
|
||||
@@ -80,7 +80,7 @@ func TestRunLogin_WithToken(t *testing.T) {
|
||||
defer restore()
|
||||
opts := &LoginOptions{
|
||||
Host: "https://kb.example.com",
|
||||
Context: "ci",
|
||||
Profile: "ci",
|
||||
WithToken: true,
|
||||
StdinReader: strings.NewReader(" sk-1234 \n"),
|
||||
}
|
||||
@@ -88,8 +88,8 @@ func TestRunLogin_WithToken(t *testing.T) {
|
||||
got, _ := store.Get("ci", "api_key")
|
||||
assert.Equal(t, "sk-1234", got)
|
||||
cfg, _ := f.Config()
|
||||
assert.Equal(t, "ci@example.com", cfg.Contexts["ci"].User, "validator-returned user should be persisted")
|
||||
assert.Equal(t, uint64(7), cfg.Contexts["ci"].TenantID)
|
||||
assert.Equal(t, "ci@example.com", cfg.Profiles["ci"].User, "validator-returned user should be persisted")
|
||||
assert.Equal(t, uint64(7), cfg.Profiles["ci"].TenantID)
|
||||
}
|
||||
|
||||
func TestRunLogin_WithToken_ServerRejects(t *testing.T) {
|
||||
@@ -103,7 +103,7 @@ func TestRunLogin_WithToken_ServerRejects(t *testing.T) {
|
||||
defer restore()
|
||||
opts := &LoginOptions{
|
||||
Host: "https://kb.example.com",
|
||||
Context: "ci",
|
||||
Profile: "ci",
|
||||
WithToken: true,
|
||||
StdinReader: strings.NewReader("sk-bad"),
|
||||
}
|
||||
@@ -135,7 +135,7 @@ func TestRunLogin_WithToken_Empty(t *testing.T) {
|
||||
defer restore()
|
||||
opts := &LoginOptions{
|
||||
Host: "https://kb.example.com",
|
||||
Context: "ci",
|
||||
Profile: "ci",
|
||||
WithToken: true,
|
||||
StdinReader: strings.NewReader(""),
|
||||
}
|
||||
@@ -156,7 +156,7 @@ func TestRunLogin_LoginRefused(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
f, _ := newTestFactoryWithConfig(t, scriptedPrompter{email: "a@b.c", password: "x"})
|
||||
svc := &fakeLoginService{resp: &sdk.LoginResponse{Success: false, Message: "bad password"}}
|
||||
err := runLogin(context.Background(), &LoginOptions{Host: "https://x", Context: "p"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, svc)
|
||||
err := runLogin(context.Background(), &LoginOptions{Host: "https://x", Profile: "p"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, svc)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "auth.bad_credential")
|
||||
}
|
||||
|
||||
@@ -79,9 +79,18 @@ func runLogout(opts *LogoutOptions, fopts *cmdutil.FormatOptions, f *cmdutil.Fac
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(cfg.Contexts) == 0 {
|
||||
if len(cfg.Profiles) == 0 {
|
||||
return cmdutil.NewError(cmdutil.CodeAuthUnauthenticated, "no profiles configured; nothing to log out")
|
||||
}
|
||||
// Reject shell-metacharacter names so opts.Name is safe to interpolate
|
||||
// into envelope.error.retry_command. Validation is name-only — the
|
||||
// profile may or may not exist in cfg.Profiles; the existence check
|
||||
// happens in pickLogoutTargets.
|
||||
if opts.Name != "" {
|
||||
if err := cmdutil.ValidateProfileName(opts.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
targets, err := pickLogoutTargets(opts, cfg)
|
||||
if err != nil {
|
||||
@@ -105,15 +114,15 @@ func runLogout(opts *LogoutOptions, fopts *cmdutil.FormatOptions, f *cmdutil.Fac
|
||||
return err
|
||||
}
|
||||
for _, name := range targets {
|
||||
clearContextSecrets(store, cfg.Contexts[name], name)
|
||||
delete(cfg.Contexts, name)
|
||||
clearProfileSecrets(store, cfg.Profiles[name], name)
|
||||
delete(cfg.Profiles, name)
|
||||
}
|
||||
// If we removed the active context, pick a remaining one (deterministic by
|
||||
// map order would be flaky - leave CurrentContext empty so the next
|
||||
// invocation surfaces a clear "no current context" error rather than
|
||||
// If we removed the active profile, pick a remaining one (deterministic by
|
||||
// map order would be flaky - leave CurrentProfile empty so the next
|
||||
// invocation surfaces a clear "no current profile" error rather than
|
||||
// silently switching).
|
||||
if _, stillExists := cfg.Contexts[cfg.CurrentContext]; !stillExists {
|
||||
cfg.CurrentContext = ""
|
||||
if _, stillExists := cfg.Profiles[cfg.CurrentProfile]; !stillExists {
|
||||
cfg.CurrentProfile = ""
|
||||
}
|
||||
if err := config.Save(cfg); err != nil {
|
||||
return cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "save config")
|
||||
@@ -129,31 +138,31 @@ func runLogout(opts *LogoutOptions, fopts *cmdutil.FormatOptions, f *cmdutil.Fac
|
||||
// pickLogoutTargets resolves the set of profiles to clear from flags + config.
|
||||
func pickLogoutTargets(opts *LogoutOptions, cfg *config.Config) ([]string, error) {
|
||||
if opts.All {
|
||||
names := make([]string, 0, len(cfg.Contexts))
|
||||
for n := range cfg.Contexts {
|
||||
names := make([]string, 0, len(cfg.Profiles))
|
||||
for n := range cfg.Profiles {
|
||||
names = append(names, n)
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
name := opts.Name
|
||||
if name == "" {
|
||||
name = cfg.CurrentContext
|
||||
name = cfg.CurrentProfile
|
||||
}
|
||||
if name == "" {
|
||||
return nil, cmdutil.NewError(cmdutil.CodeInputMissingFlag,
|
||||
"no current profile set; pass --name <profile> or --all")
|
||||
}
|
||||
if _, ok := cfg.Contexts[name]; !ok {
|
||||
if _, ok := cfg.Profiles[name]; !ok {
|
||||
return nil, cmdutil.NewError(cmdutil.CodeLocalProfileNotFound,
|
||||
fmt.Sprintf("profile %q not found in config", name))
|
||||
}
|
||||
return []string{name}, nil
|
||||
}
|
||||
|
||||
// clearContextSecrets best-effort deletes every secret slot the profile
|
||||
// clearProfileSecrets best-effort deletes every secret slot the profile
|
||||
// references. Errors are swallowed because a missing secret is a no-op
|
||||
// (tested in keyring_test.go) - we don't want a stale ref to block logout.
|
||||
func clearContextSecrets(store secrets.Store, c config.Context, name string) {
|
||||
func clearProfileSecrets(store secrets.Store, c config.Profile, name string) {
|
||||
if c.TokenRef != "" {
|
||||
_ = store.Delete(name, "access")
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ func isolateConfig(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
}
|
||||
|
||||
func TestLogout_CurrentContext(t *testing.T) {
|
||||
func TestLogout_CurrentProfile(t *testing.T) {
|
||||
isolateConfig(t)
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
store := secrets.NewMemStore()
|
||||
@@ -41,19 +41,19 @@ func TestLogout_CurrentContext(t *testing.T) {
|
||||
require.NoError(t, store.Set("staging", "api_key", "sk-staging"))
|
||||
|
||||
cfg := &config.Config{
|
||||
CurrentContext: "prod",
|
||||
Contexts: map[string]config.Context{
|
||||
CurrentProfile: "prod",
|
||||
Profiles: map[string]config.Profile{
|
||||
"prod": {Host: "https://prod", TokenRef: store.Ref("prod", "access"), RefreshRef: store.Ref("prod", "refresh")},
|
||||
"staging": {Host: "https://staging", APIKeyRef: store.Ref("staging", "api_key")},
|
||||
},
|
||||
}
|
||||
require.NoError(t, runLogout(&LogoutOptions{Yes: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, newLogoutFactory(t, cfg, store)))
|
||||
|
||||
assert.Empty(t, cfg.CurrentContext, "current_context should clear when removed")
|
||||
assert.NotContains(t, cfg.Contexts, "prod")
|
||||
assert.Contains(t, cfg.Contexts, "staging", "non-target context untouched")
|
||||
assert.Empty(t, cfg.CurrentProfile, "current_profile should clear when removed")
|
||||
assert.NotContains(t, cfg.Profiles, "prod")
|
||||
assert.Contains(t, cfg.Profiles, "staging", "non-target profile untouched")
|
||||
|
||||
// Secrets gone for the removed context, kept for the survivor.
|
||||
// Secrets gone for the removed profile, kept for the survivor.
|
||||
if _, err := store.Get("prod", "access"); err == nil {
|
||||
t.Error("prod access secret should be deleted")
|
||||
}
|
||||
@@ -69,17 +69,17 @@ func TestLogout_NamedContext(t *testing.T) {
|
||||
require.NoError(t, store.Set("staging", "api_key", "sk-staging"))
|
||||
|
||||
cfg := &config.Config{
|
||||
CurrentContext: "prod",
|
||||
Contexts: map[string]config.Context{
|
||||
CurrentProfile: "prod",
|
||||
Profiles: map[string]config.Profile{
|
||||
"prod": {Host: "https://prod", TokenRef: "tok"},
|
||||
"staging": {Host: "https://staging", APIKeyRef: store.Ref("staging", "api_key")},
|
||||
},
|
||||
}
|
||||
require.NoError(t, runLogout(&LogoutOptions{Name: "staging", Yes: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, newLogoutFactory(t, cfg, store)))
|
||||
|
||||
assert.Equal(t, "prod", cfg.CurrentContext, "current_context untouched when removing other")
|
||||
assert.NotContains(t, cfg.Contexts, "staging")
|
||||
assert.Contains(t, cfg.Contexts, "prod")
|
||||
assert.Equal(t, "prod", cfg.CurrentProfile, "current_profile untouched when removing other")
|
||||
assert.NotContains(t, cfg.Profiles, "staging")
|
||||
assert.Contains(t, cfg.Profiles, "prod")
|
||||
}
|
||||
|
||||
func TestLogout_All(t *testing.T) {
|
||||
@@ -87,19 +87,19 @@ func TestLogout_All(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
store := secrets.NewMemStore()
|
||||
cfg := &config.Config{
|
||||
CurrentContext: "prod",
|
||||
Contexts: map[string]config.Context{
|
||||
CurrentProfile: "prod",
|
||||
Profiles: map[string]config.Profile{
|
||||
"prod": {Host: "https://prod"},
|
||||
"staging": {Host: "https://staging"},
|
||||
},
|
||||
}
|
||||
require.NoError(t, runLogout(&LogoutOptions{All: true, Yes: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, newLogoutFactory(t, cfg, store)))
|
||||
|
||||
assert.Empty(t, cfg.Contexts)
|
||||
assert.Empty(t, cfg.CurrentContext)
|
||||
assert.Empty(t, cfg.Profiles)
|
||||
assert.Empty(t, cfg.CurrentProfile)
|
||||
}
|
||||
|
||||
func TestLogout_NoContexts(t *testing.T) {
|
||||
func TestLogout_NoProfiles(t *testing.T) {
|
||||
isolateConfig(t)
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
cfg := &config.Config{}
|
||||
@@ -114,8 +114,8 @@ func TestLogout_UnknownName(t *testing.T) {
|
||||
isolateConfig(t)
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
cfg := &config.Config{
|
||||
CurrentContext: "prod",
|
||||
Contexts: map[string]config.Context{"prod": {Host: "https://prod"}},
|
||||
CurrentProfile: "prod",
|
||||
Profiles: map[string]config.Profile{"prod": {Host: "https://prod"}},
|
||||
}
|
||||
err := runLogout(&LogoutOptions{Name: "ghost"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, newLogoutFactory(t, cfg, secrets.NewMemStore()))
|
||||
require.Error(t, err)
|
||||
@@ -128,7 +128,7 @@ func TestLogout_NoCurrentNoFlag(t *testing.T) {
|
||||
isolateConfig(t)
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
cfg := &config.Config{
|
||||
Contexts: map[string]config.Context{"prod": {Host: "https://prod"}},
|
||||
Profiles: map[string]config.Profile{"prod": {Host: "https://prod"}},
|
||||
}
|
||||
err := runLogout(&LogoutOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, newLogoutFactory(t, cfg, secrets.NewMemStore()))
|
||||
require.Error(t, err)
|
||||
@@ -142,7 +142,7 @@ func TestLogout_NoCurrentNoFlag(t *testing.T) {
|
||||
func TestLogout_Cobra_FlagsMutuallyExclusive(t *testing.T) {
|
||||
isolateConfig(t)
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
cfg := &config.Config{Contexts: map[string]config.Context{"a": {}}}
|
||||
cfg := &config.Config{Profiles: map[string]config.Profile{"a": {}}}
|
||||
cmd := NewCmdLogout(newLogoutFactory(t, cfg, secrets.NewMemStore()))
|
||||
cmd.SetContext(context.Background())
|
||||
cmd.SetArgs([]string{"--name", "a", "--all"})
|
||||
|
||||
@@ -75,13 +75,13 @@ func runRefresh(ctx context.Context, opts *RefreshOptions, fopts *cmdutil.Format
|
||||
}
|
||||
name := opts.Name
|
||||
if name == "" {
|
||||
name = cfg.CurrentContext
|
||||
name = cfg.CurrentProfile
|
||||
}
|
||||
if name == "" {
|
||||
return cmdutil.NewError(cmdutil.CodeAuthUnauthenticated,
|
||||
"no current profile configured; run `weknora auth login` to set one up")
|
||||
}
|
||||
c, ok := cfg.Contexts[name]
|
||||
c, ok := cfg.Profiles[name]
|
||||
if !ok {
|
||||
return cmdutil.NewError(cmdutil.CodeLocalProfileNotFound,
|
||||
fmt.Sprintf("profile not found: %s", name))
|
||||
|
||||
@@ -56,8 +56,8 @@ func TestRefresh_Happy(t *testing.T) {
|
||||
require.NoError(t, store.Set("prod", "refresh", "old-refresh"))
|
||||
|
||||
cfg := &config.Config{
|
||||
CurrentContext: "prod",
|
||||
Contexts: map[string]config.Context{
|
||||
CurrentProfile: "prod",
|
||||
Profiles: map[string]config.Profile{
|
||||
"prod": {
|
||||
Host: "https://kb.example.com",
|
||||
TokenRef: "mem://prod/access",
|
||||
@@ -87,8 +87,8 @@ func TestRefresh_NamedContext(t *testing.T) {
|
||||
require.NoError(t, store.Set("staging", "refresh", "stg-refresh"))
|
||||
|
||||
cfg := &config.Config{
|
||||
CurrentContext: "prod",
|
||||
Contexts: map[string]config.Context{
|
||||
CurrentProfile: "prod",
|
||||
Profiles: map[string]config.Profile{
|
||||
"prod": {Host: "https://prod", TokenRef: "mem://prod/access", RefreshRef: "mem://prod/refresh"},
|
||||
"staging": {Host: "https://stg", TokenRef: "mem://staging/access", RefreshRef: "mem://staging/refresh"},
|
||||
},
|
||||
@@ -99,14 +99,14 @@ func TestRefresh_NamedContext(t *testing.T) {
|
||||
}}
|
||||
require.NoError(t, runRefresh(context.Background(), &RefreshOptions{Name: "staging"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, stubSvc(svc)))
|
||||
|
||||
assert.Equal(t, "stg-refresh", svc.gotTok, "--name=staging must refresh the staging context, not current")
|
||||
assert.Equal(t, "stg-refresh", svc.gotTok, "--name=staging must refresh the staging profile, not current")
|
||||
// current is untouched
|
||||
if v, _ := store.Get("prod", "access"); v != "" {
|
||||
t.Errorf("prod must not have been touched, got %q", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefresh_NoCurrentContext(t *testing.T) {
|
||||
func TestRefresh_NoCurrentProfile(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
f := newRefreshFactory(t, &config.Config{}, secrets.NewMemStore())
|
||||
err := runRefresh(context.Background(), &RefreshOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, stubSvc(&fakeRefreshService{}))
|
||||
@@ -121,8 +121,8 @@ func TestRefresh_APIKeyContext(t *testing.T) {
|
||||
store := secrets.NewMemStore()
|
||||
require.NoError(t, store.Set("ci", "api_key", "sk-123"))
|
||||
cfg := &config.Config{
|
||||
CurrentContext: "ci",
|
||||
Contexts: map[string]config.Context{"ci": {Host: "https://kb", APIKeyRef: "mem://ci/api_key"}},
|
||||
CurrentProfile: "ci",
|
||||
Profiles: map[string]config.Profile{"ci": {Host: "https://kb", APIKeyRef: "mem://ci/api_key"}},
|
||||
}
|
||||
f := newRefreshFactory(t, cfg, store)
|
||||
err := runRefresh(context.Background(), &RefreshOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, stubSvc(&fakeRefreshService{}))
|
||||
@@ -130,14 +130,14 @@ func TestRefresh_APIKeyContext(t *testing.T) {
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code)
|
||||
assert.Contains(t, typed.Hint, "api-key", "hint should explain api-key contexts cannot be refreshed")
|
||||
assert.Contains(t, typed.Hint, "api-key", "hint should explain api-key profiles cannot be refreshed")
|
||||
}
|
||||
|
||||
func TestRefresh_NoRefreshTokenStored(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
cfg := &config.Config{
|
||||
CurrentContext: "prod",
|
||||
Contexts: map[string]config.Context{
|
||||
CurrentProfile: "prod",
|
||||
Profiles: map[string]config.Profile{
|
||||
"prod": {Host: "https://kb", TokenRef: "mem://prod/access", RefreshRef: "mem://prod/refresh"},
|
||||
},
|
||||
}
|
||||
@@ -156,8 +156,8 @@ func TestRefresh_ServerRefused(t *testing.T) {
|
||||
store := secrets.NewMemStore()
|
||||
require.NoError(t, store.Set("prod", "refresh", "stale-refresh"))
|
||||
cfg := &config.Config{
|
||||
CurrentContext: "prod",
|
||||
Contexts: map[string]config.Context{"prod": {Host: "https://kb", TokenRef: "mem://prod/access", RefreshRef: "mem://prod/refresh"}},
|
||||
CurrentProfile: "prod",
|
||||
Profiles: map[string]config.Profile{"prod": {Host: "https://kb", TokenRef: "mem://prod/access", RefreshRef: "mem://prod/refresh"}},
|
||||
}
|
||||
f := newRefreshFactory(t, cfg, store)
|
||||
svc := &fakeRefreshService{resp: &sdk.RefreshTokenResponse{Success: false, Message: "refresh token expired"}}
|
||||
@@ -179,8 +179,8 @@ func TestRefresh_TransportError(t *testing.T) {
|
||||
store := secrets.NewMemStore()
|
||||
require.NoError(t, store.Set("prod", "refresh", "ok-refresh"))
|
||||
cfg := &config.Config{
|
||||
CurrentContext: "prod",
|
||||
Contexts: map[string]config.Context{"prod": {Host: "https://kb", TokenRef: "mem://prod/access", RefreshRef: "mem://prod/refresh"}},
|
||||
CurrentProfile: "prod",
|
||||
Profiles: map[string]config.Profile{"prod": {Host: "https://kb", TokenRef: "mem://prod/access", RefreshRef: "mem://prod/refresh"}},
|
||||
}
|
||||
f := newRefreshFactory(t, cfg, store)
|
||||
svc := &fakeRefreshService{err: errors.New("connection reset")}
|
||||
@@ -199,8 +199,8 @@ func TestRefresh_JSONOutput(t *testing.T) {
|
||||
store := secrets.NewMemStore()
|
||||
require.NoError(t, store.Set("prod", "refresh", "ok-refresh"))
|
||||
cfg := &config.Config{
|
||||
CurrentContext: "prod",
|
||||
Contexts: map[string]config.Context{"prod": {Host: "https://kb", TokenRef: "mem://prod/access", RefreshRef: "mem://prod/refresh"}},
|
||||
CurrentProfile: "prod",
|
||||
Profiles: map[string]config.Profile{"prod": {Host: "https://kb", TokenRef: "mem://prod/access", RefreshRef: "mem://prod/refresh"}},
|
||||
}
|
||||
f := newRefreshFactory(t, cfg, store)
|
||||
svc := &fakeRefreshService{resp: &sdk.RefreshTokenResponse{Success: true, AccessToken: "a", RefreshToken: "r"}}
|
||||
@@ -211,8 +211,8 @@ func TestRefresh_JSONOutput(t *testing.T) {
|
||||
assert.NotContains(t, body, "ok-refresh", "output must not leak refresh token")
|
||||
assert.NotContains(t, body, "\"a\"", "output must not leak the new access token")
|
||||
assert.NotContains(t, body, "\"r\"", "output must not leak the new refresh token")
|
||||
// must mention the context name so agents can confirm what was refreshed
|
||||
assert.True(t, strings.Contains(body, "prod"), "output should reference the refreshed context")
|
||||
// must mention the profile name so agents can confirm what was refreshed
|
||||
assert.True(t, strings.Contains(body, "prod"), "output should reference the refreshed profile")
|
||||
// v0.7 envelope: ok:true is expected
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
|
||||
@@ -85,7 +85,7 @@ func runStatus(ctx context.Context, fopts *cmdutil.FormatOptions, f *cmdutil.Fac
|
||||
}
|
||||
|
||||
if fopts.WantsJSON() {
|
||||
result := statusResult{Profile: cfg.CurrentContext}
|
||||
result := statusResult{Profile: cfg.CurrentProfile}
|
||||
if user != nil {
|
||||
result.UserID = user.ID
|
||||
result.Username = user.Username
|
||||
@@ -101,10 +101,10 @@ func runStatus(ctx context.Context, fopts *cmdutil.FormatOptions, f *cmdutil.Fac
|
||||
}
|
||||
|
||||
host := ""
|
||||
if c, ok := cfg.Contexts[cfg.CurrentContext]; ok {
|
||||
if c, ok := cfg.Profiles[cfg.CurrentProfile]; ok {
|
||||
host = c.Host
|
||||
}
|
||||
fmt.Fprintf(iostreams.IO.Out, "profile: %s\n", cfg.CurrentContext)
|
||||
fmt.Fprintf(iostreams.IO.Out, "profile: %s\n", cfg.CurrentProfile)
|
||||
fmt.Fprintf(iostreams.IO.Out, "host: %s\n", host)
|
||||
if user != nil {
|
||||
fmt.Fprintf(iostreams.IO.Out, "user: %s (%s)\n", user.Email, user.ID)
|
||||
|
||||
@@ -37,8 +37,8 @@ func TestRunStatus_TextOutput(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
testutil.XDGTempDir(t)
|
||||
require.NoError(t, config.Save(&config.Config{
|
||||
CurrentContext: "prod",
|
||||
Contexts: map[string]config.Context{
|
||||
CurrentProfile: "prod",
|
||||
Profiles: map[string]config.Profile{
|
||||
"prod": {Host: "https://kb.example.com", TenantID: 7},
|
||||
},
|
||||
}))
|
||||
@@ -64,8 +64,8 @@ func TestRunStatus_JSONOutput(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
testutil.XDGTempDir(t)
|
||||
require.NoError(t, config.Save(&config.Config{
|
||||
CurrentContext: "prod",
|
||||
Contexts: map[string]config.Context{"prod": {Host: "https://x"}},
|
||||
CurrentProfile: "prod",
|
||||
Profiles: map[string]config.Profile{"prod": {Host: "https://x"}},
|
||||
}))
|
||||
f := &cmdutil.Factory{Config: func() (*config.Config, error) { return config.Load() }}
|
||||
svc := &fakeStatusService{resp: newCurrentUserResponse(&sdk.AuthUser{ID: "u1", Email: "a@b.c", TenantID: 7}, nil)}
|
||||
@@ -91,7 +91,7 @@ func TestRunStatus_NoSDKClient(t *testing.T) {
|
||||
func TestRunStatus_SDKError_Transport(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
testutil.XDGTempDir(t)
|
||||
require.NoError(t, config.Save(&config.Config{CurrentContext: "p", Contexts: map[string]config.Context{"p": {Host: "https://x"}}}))
|
||||
require.NoError(t, config.Save(&config.Config{CurrentProfile: "p", Profiles: map[string]config.Profile{"p": {Host: "https://x"}}}))
|
||||
f := &cmdutil.Factory{Config: func() (*config.Config, error) { return config.Load() }}
|
||||
err := runStatus(context.Background(), &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, &fakeStatusService{err: assert.AnError})
|
||||
require.Error(t, err)
|
||||
@@ -103,7 +103,7 @@ func TestRunStatus_SDKError_Transport(t *testing.T) {
|
||||
func TestRunStatus_SDKError_HTTP401(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
testutil.XDGTempDir(t)
|
||||
require.NoError(t, config.Save(&config.Config{CurrentContext: "p", Contexts: map[string]config.Context{"p": {Host: "https://x"}}}))
|
||||
require.NoError(t, config.Save(&config.Config{CurrentProfile: "p", Profiles: map[string]config.Profile{"p": {Host: "https://x"}}}))
|
||||
f := &cmdutil.Factory{Config: func() (*config.Config, error) { return config.Load() }}
|
||||
err := runStatus(context.Background(), &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, &fakeStatusService{err: errors.New("HTTP error 401: invalid token")})
|
||||
require.Error(t, err)
|
||||
|
||||
@@ -68,18 +68,18 @@ func runToken(f *cmdutil.Factory, fopts *cmdutil.FormatOptions) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctxName := cfg.CurrentContext
|
||||
profileName := cfg.CurrentProfile
|
||||
if f.ProfileOverride != "" {
|
||||
ctxName = f.ProfileOverride
|
||||
profileName = f.ProfileOverride
|
||||
}
|
||||
if ctxName == "" {
|
||||
if profileName == "" {
|
||||
return cmdutil.NewError(cmdutil.CodeAuthUnauthenticated,
|
||||
"no current profile configured")
|
||||
}
|
||||
ctx, ok := cfg.Contexts[ctxName]
|
||||
ctx, ok := cfg.Profiles[profileName]
|
||||
if !ok {
|
||||
return cmdutil.NewError(cmdutil.CodeLocalProfileNotFound,
|
||||
fmt.Sprintf("profile %q not found", ctxName))
|
||||
fmt.Sprintf("profile %q not found", profileName))
|
||||
}
|
||||
|
||||
store, err := f.Secrets()
|
||||
@@ -93,29 +93,29 @@ func runToken(f *cmdutil.Factory, fopts *cmdutil.FormatOptions) error {
|
||||
var token, mode string
|
||||
switch {
|
||||
case ctx.TokenRef != "":
|
||||
v, ferr := cmdutil.LoadSecret(store, ctxName, "access")
|
||||
v, ferr := cmdutil.LoadSecret(store, profileName, "access")
|
||||
if ferr != nil {
|
||||
return ferr
|
||||
}
|
||||
token, mode = v, ModeBearer
|
||||
case ctx.APIKeyRef != "":
|
||||
v, ferr := cmdutil.LoadSecret(store, ctxName, "api_key")
|
||||
v, ferr := cmdutil.LoadSecret(store, profileName, "api_key")
|
||||
if ferr != nil {
|
||||
return ferr
|
||||
}
|
||||
token, mode = v, ModeAPIKey
|
||||
default:
|
||||
return cmdutil.NewError(cmdutil.CodeAuthUnauthenticated,
|
||||
fmt.Sprintf("profile %q has no stored credential; run `weknora auth login`", ctxName))
|
||||
fmt.Sprintf("profile %q has no stored credential; run `weknora auth login`", profileName))
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
return cmdutil.NewError(cmdutil.CodeAuthUnauthenticated,
|
||||
fmt.Sprintf("profile %q credential is empty in keyring; run `weknora auth login`", ctxName))
|
||||
fmt.Sprintf("profile %q credential is empty in keyring; run `weknora auth login`", profileName))
|
||||
}
|
||||
|
||||
if fopts.WantsJSON() {
|
||||
return fopts.Emit(iostreams.IO.Out, tokenResult{Token: token, Mode: mode, Profile: ctxName}, nil)
|
||||
return fopts.Emit(iostreams.IO.Out, tokenResult{Token: token, Mode: mode, Profile: profileName}, nil)
|
||||
}
|
||||
|
||||
// No trailing newline - clean $(weknora auth token) substitution.
|
||||
|
||||
@@ -25,8 +25,8 @@ func tokenTestFactory(t *testing.T, cfg *config.Config, store *secrets.MemStore)
|
||||
|
||||
func TestAuthToken_BearerMode_PlainOutput(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
CurrentContext: "prod",
|
||||
Contexts: map[string]config.Context{
|
||||
CurrentProfile: "prod",
|
||||
Profiles: map[string]config.Profile{
|
||||
"prod": {Host: "https://kb.example.com", TokenRef: "prod:access", RefreshRef: "prod:refresh"},
|
||||
},
|
||||
}
|
||||
@@ -49,8 +49,8 @@ func TestAuthToken_BearerMode_PlainOutput(t *testing.T) {
|
||||
|
||||
func TestAuthToken_APIKeyMode_PlainOutput(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
CurrentContext: "ci",
|
||||
Contexts: map[string]config.Context{
|
||||
CurrentProfile: "ci",
|
||||
Profiles: map[string]config.Profile{
|
||||
"ci": {Host: "https://kb.example.com", APIKeyRef: "ci:api_key"},
|
||||
},
|
||||
}
|
||||
@@ -68,8 +68,8 @@ func TestAuthToken_APIKeyMode_PlainOutput(t *testing.T) {
|
||||
|
||||
func TestAuthToken_JSON(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
CurrentContext: "prod",
|
||||
Contexts: map[string]config.Context{
|
||||
CurrentProfile: "prod",
|
||||
Profiles: map[string]config.Profile{
|
||||
"prod": {Host: "https://kb.example.com", TokenRef: "prod:access"},
|
||||
},
|
||||
}
|
||||
@@ -99,8 +99,8 @@ func TestAuthToken_JSON(t *testing.T) {
|
||||
|
||||
func TestAuthToken_JSON_JQProjection(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
CurrentContext: "ci",
|
||||
Contexts: map[string]config.Context{
|
||||
CurrentProfile: "ci",
|
||||
Profiles: map[string]config.Profile{
|
||||
"ci": {Host: "https://kb.example.com", APIKeyRef: "ci:api_key"},
|
||||
},
|
||||
}
|
||||
@@ -125,7 +125,7 @@ func TestAuthToken_JSON_JQProjection(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthToken_NoCurrentContext(t *testing.T) {
|
||||
func TestAuthToken_NoCurrentProfile(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
store := secrets.NewMemStore()
|
||||
iostreams.SetForTest(t)
|
||||
@@ -140,8 +140,8 @@ func TestAuthToken_NoCurrentContext(t *testing.T) {
|
||||
|
||||
func TestAuthToken_ProfileOverride(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
CurrentContext: "prod",
|
||||
Contexts: map[string]config.Context{
|
||||
CurrentProfile: "prod",
|
||||
Profiles: map[string]config.Profile{
|
||||
"prod": {Host: "https://prod.example.com", TokenRef: "prod:access"},
|
||||
"staging": {Host: "https://staging.example.com", APIKeyRef: "staging:api_key"},
|
||||
},
|
||||
@@ -164,8 +164,8 @@ func TestAuthToken_ProfileOverride(t *testing.T) {
|
||||
|
||||
func TestAuthToken_NoStoredCredential(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
CurrentContext: "prod",
|
||||
Contexts: map[string]config.Context{
|
||||
CurrentProfile: "prod",
|
||||
Profiles: map[string]config.Profile{
|
||||
"prod": {Host: "https://kb.example.com", TokenRef: "prod:access"},
|
||||
},
|
||||
}
|
||||
@@ -183,8 +183,8 @@ func TestAuthToken_NoStoredCredential(t *testing.T) {
|
||||
|
||||
func TestAuthToken_ContextWithNoCredentialRefs(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
CurrentContext: "empty",
|
||||
Contexts: map[string]config.Context{
|
||||
CurrentProfile: "empty",
|
||||
Profiles: map[string]config.Profile{
|
||||
"empty": {Host: "https://kb.example.com"}, // no TokenRef or APIKeyRef
|
||||
},
|
||||
}
|
||||
@@ -209,8 +209,8 @@ func TestAuthToken_ContextWithNoCredentialRefs(t *testing.T) {
|
||||
|
||||
func makeBearerCfg() (*config.Config, *secrets.MemStore) {
|
||||
cfg := &config.Config{
|
||||
CurrentContext: "prod",
|
||||
Contexts: map[string]config.Context{
|
||||
CurrentProfile: "prod",
|
||||
Profiles: map[string]config.Profile{
|
||||
"prod": {Host: "https://kb.example.com", TokenRef: "prod:access"},
|
||||
},
|
||||
}
|
||||
@@ -221,8 +221,8 @@ func makeBearerCfg() (*config.Config, *secrets.MemStore) {
|
||||
|
||||
func makeAPIKeyCfg() (*config.Config, *secrets.MemStore) {
|
||||
cfg := &config.Config{
|
||||
CurrentContext: "ci",
|
||||
Contexts: map[string]config.Context{
|
||||
CurrentProfile: "ci",
|
||||
Profiles: map[string]config.Profile{
|
||||
"ci": {Host: "https://kb.example.com", APIKeyRef: "ci:api_key"},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
// "init" event at stream head, then pass through every SDK event verbatim
|
||||
// as NDJSON lines. Agents and pipes get a live event stream they can
|
||||
// parse incrementally. --format json routes here too — buffered JSON
|
||||
// envelope makes no sense for a streaming command (§5).
|
||||
// envelope makes no sense for a streaming command.
|
||||
//
|
||||
// The SDK's KnowledgeQAStream callback contract is invoked sequentially on
|
||||
// one goroutine, so neither mode needs locking. The runChat core takes a
|
||||
@@ -37,7 +37,7 @@ import (
|
||||
|
||||
// chatFields enumerates the NDJSON init-event fields surfaced for
|
||||
// `--format json` / `--format ndjson` discovery on `chat`. Reflects the
|
||||
// InitEvent head line + the raw SDK event vocabulary (§5).
|
||||
// InitEvent head line + the raw SDK event vocabulary.
|
||||
var chatFields = []string{
|
||||
"session_id", "kb_id",
|
||||
// SDK event fields (pass-through): response_type, content, done,
|
||||
@@ -74,13 +74,13 @@ Modes:
|
||||
one init line at head (session_id, kb_id),
|
||||
then raw SDK events verbatim. Both json
|
||||
and ndjson flags produce the same NDJSON
|
||||
stream (§5).`,
|
||||
stream.`,
|
||||
Example: ` weknora chat "What is RRF?" --kb a32a63ff-fb36-4874-bcaa-30f48570a694
|
||||
weknora chat "Summarise this design doc" --kb my-kb --format json
|
||||
weknora chat "Continue?" --session sess_abc`,
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
opts.Query = strings.TrimSpace(strings.Join(args, " "))
|
||||
opts.Query = strings.TrimSpace(args[0])
|
||||
if opts.Query == "" {
|
||||
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "query argument cannot be empty")
|
||||
}
|
||||
@@ -101,7 +101,7 @@ Modes:
|
||||
return runChat(c.Context(), opts, fopts, cli)
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("kb", "", "Knowledge base UUID or name (overrides project link / env)")
|
||||
cmdutil.AddKBFlag(cmd)
|
||||
cmd.Flags().StringVar(&opts.SessionID, "session", "", "Continue an existing chat session (skip auto-create)")
|
||||
cmdutil.AddFormatFlag(cmd, chatFields...)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
@@ -130,7 +130,7 @@ func runChat(ctx context.Context, opts *Options, fopts *cmdutil.FormatOptions, s
|
||||
|
||||
// Streaming commands route --format json AND --format ndjson to the
|
||||
// NDJSON event-stream path. A buffered envelope makes no sense for a
|
||||
// streaming command (§5). Only --format text uses the live renderer.
|
||||
// streaming command. Only --format text uses the live renderer.
|
||||
ndjsonMode := fopts != nil && (fopts.Mode == cmdutil.FormatJSON || fopts.Mode == cmdutil.FormatNDJSON)
|
||||
|
||||
sessionID := opts.SessionID
|
||||
@@ -173,11 +173,11 @@ func runChat(ctx context.Context, opts *Options, fopts *cmdutil.FormatOptions, s
|
||||
// runChatNDJSON handles --format json and --format ndjson paths.
|
||||
// Emits a CLI init event at stream head, then passes every SDK event through
|
||||
// verbatim as NDJSON lines. No buffering — callers parse the stream
|
||||
// incrementally (§5).
|
||||
// incrementally.
|
||||
func runChatNDJSON(ctx context.Context, opts *Options, sessionID string, svc ChatService) error {
|
||||
w := iostreams.IO.Out
|
||||
|
||||
// 1. Inject the CLI-managed init event at the head of the stream (§5.3).
|
||||
// 1. Inject the CLI-managed init event at the head of the stream.
|
||||
// Carries the session pointer + retrieval context callers need for
|
||||
// follow-up threading.
|
||||
initEv := output.InitEvent{
|
||||
|
||||
@@ -188,7 +188,7 @@ func TestMultiDelete_NonTTY_NoYes_RequiresConfirmation(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestChunkDelete_MultiID_PartialFailure_BatchEnvelope verifies that the JSON
|
||||
// output for a multi-id partial failure uses the §4.5 batch envelope shape:
|
||||
// output for a multi-id partial failure uses the batch envelope shape:
|
||||
// {ok:false, data:[{id,ok,result?|error?}...], meta:{count,successes,failures}}.
|
||||
func TestChunkDelete_MultiID_PartialFailure_BatchEnvelope(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
|
||||
@@ -77,7 +77,7 @@ don't require a file upload or remote URL. KB resolution follows the standard
|
||||
return runCreate(c.Context(), opts, fopts, cli, kbID)
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("kb", "", "Knowledge base UUID or name (overrides env / project link)")
|
||||
cmdutil.AddKBFlag(cmd)
|
||||
cmd.Flags().StringVar(&opts.Text, "text", "", "Document text content in Markdown format (required)")
|
||||
cmd.Flags().StringVar(&opts.Name, "name", "", "Document title")
|
||||
cmd.Flags().StringVar(&opts.TagID, "tag-id", "", "Tag id to associate with the new entry")
|
||||
|
||||
@@ -280,7 +280,7 @@ func TestRunMultiDelete_ConfirmBatch_TTY_UserAborts(t *testing.T) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Emit tests — JSON path now emits batch envelope (§4.5)
|
||||
// Emit tests — JSON path now emits the batch envelope
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// batchEnvelope is a minimal struct for parsing the batch envelope shape.
|
||||
@@ -380,7 +380,7 @@ func TestEmitMultiDelete_UnsupportedFormat(t *testing.T) {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestDocDelete_MultiID_PartialFailure_BatchEnvelope verifies that when a
|
||||
// multi-id delete has a partial failure, stdout carries the §4.5 batch
|
||||
// multi-id delete has a partial failure, stdout carries the batch
|
||||
// envelope shape: ok:false, data:[BatchItem...], meta:{count, successes,
|
||||
// failures}. Order follows original argv order.
|
||||
func TestDocDelete_MultiID_PartialFailure_BatchEnvelope(t *testing.T) {
|
||||
|
||||
@@ -105,7 +105,7 @@ Server-side ingestion knobs:
|
||||
return runFetch(c.Context(), opts, fopts, cli, kbID)
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("kb", "", "Knowledge base UUID or name (overrides env / project link)")
|
||||
cmdutil.AddKBFlag(cmd)
|
||||
cmd.Flags().StringVar(&opts.Name, "name", "", "File name hint (also used as file-type hint when extension is recognisable)")
|
||||
cmd.Flags().StringVar(&opts.Title, "title", "", "Display title for the new entry")
|
||||
cmd.Flags().StringVar(&opts.FileType, "file-type", "", "File-type hint such as \"pdf\" when the URL has no extension")
|
||||
|
||||
@@ -99,9 +99,7 @@ backend storage order is not guaranteed and varies between deployments.`,
|
||||
return runList(c.Context(), opts, fopts, cli, kbID)
|
||||
},
|
||||
}
|
||||
// --kb is read by Factory.ResolveKB; declare it here so cobra parses the
|
||||
// value into the command's flag set.
|
||||
cmd.Flags().String("kb", "", "Knowledge base UUID or name (overrides env / project link)")
|
||||
cmdutil.AddKBFlag(cmd)
|
||||
cmd.Flags().IntVar(&opts.PageSize, "page-size", 50, "Items per server batch (1..1000)")
|
||||
cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 30, "Maximum results to return (1..10000)")
|
||||
cmd.Flags().BoolVar(&opts.AllPages, "all-pages", false, "Walk all server pages until exhausted (or --limit hit)")
|
||||
|
||||
@@ -133,8 +133,8 @@ func TestList_KBIDRequired(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
|
||||
cfg := &config.Config{
|
||||
CurrentContext: "default",
|
||||
Contexts: map[string]config.Context{"default": {Host: "https://example"}},
|
||||
CurrentProfile: "default",
|
||||
Profiles: map[string]config.Profile{"default": {Host: "https://example"}},
|
||||
}
|
||||
f := &cmdutil.Factory{
|
||||
Config: func() (*config.Config, error) { return cfg, nil },
|
||||
@@ -162,8 +162,8 @@ func TestList_KBFlagWiredToResolveKB(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
|
||||
cfg := &config.Config{
|
||||
CurrentContext: "default",
|
||||
Contexts: map[string]config.Context{"default": {Host: "https://example"}},
|
||||
CurrentProfile: "default",
|
||||
Profiles: map[string]config.Profile{"default": {Host: "https://example"}},
|
||||
}
|
||||
f := &cmdutil.Factory{
|
||||
Config: func() (*config.Config, error) { return cfg, nil },
|
||||
|
||||
@@ -140,7 +140,7 @@ Server-side ingestion knobs:
|
||||
return runUpload(c.Context(), opts, fopts, cli, kbID, args[0])
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("kb", "", "Knowledge base UUID or name (overrides env / project link)")
|
||||
cmdutil.AddKBFlag(cmd)
|
||||
cmd.Flags().StringVar(&opts.Name, "name", "", "Custom file name to record (defaults to base name)")
|
||||
cmd.Flags().BoolVar(&opts.Recursive, "recursive", false, "Treat the positional argument as a directory to walk")
|
||||
cmd.Flags().StringVar(&opts.Glob, "glob", "*", "Filename pattern to filter when --recursive (e.g. '*.pdf')")
|
||||
|
||||
@@ -195,7 +195,7 @@ func TestUploadRecursive_MetadataInvalid_NoCalls(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestUploadRecursive_JSON_BatchEnvelope verifies that --format json emits the
|
||||
// §4.5 batch envelope shape: {ok, data:[{id,ok,result?|error?}...], meta:{count,successes,failures}}.
|
||||
// batch envelope shape: {ok, data:[{id,ok,result?|error?}...], meta:{count,successes,failures}}.
|
||||
// The per-item id is the file path; result carries {id, name} from the server.
|
||||
func TestUploadRecursive_JSON_BatchEnvelope(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
|
||||
@@ -239,9 +239,9 @@ func waitForDocs(ctx context.Context, ids []string, svc WaitService, opts WaitOp
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ExitCode resolves the compound terminal state to a Unix exit code per
|
||||
// spec §3.2: priority 1 > 124 > 0 (failed > timeout > completed). SIGINT
|
||||
// (exit 130) is handled by the Go runtime / context cancellation, not here.
|
||||
// ExitCode resolves the compound terminal state to a Unix exit code.
|
||||
// Priority: 1 > 124 > 0 (failed > timeout > completed). SIGINT (exit 130)
|
||||
// is handled by the Go runtime / context cancellation, not here.
|
||||
func (r *WaitResult) ExitCode() int {
|
||||
if len(r.Failed) > 0 {
|
||||
return 1
|
||||
|
||||
@@ -164,7 +164,7 @@ func runChecks(ctx context.Context, opts *Options, svc Services, cliVer string)
|
||||
t0 := time.Now()
|
||||
if err := svc.PingBaseURL(ctx); err != nil {
|
||||
checks[0].Status = StatusFail
|
||||
checks[0].Hint = "verify the host configured for the active context (run `weknora auth login --host=...`) and network reachability"
|
||||
checks[0].Hint = "verify the host configured for the active profile (run `weknora auth login --host=...`) and network reachability"
|
||||
checks[0].Details = err.Error()
|
||||
} else {
|
||||
checks[0].Status = StatusOK
|
||||
@@ -373,7 +373,7 @@ func marker(s Status) string {
|
||||
}
|
||||
|
||||
// buildServices wires the Factory closures into the doctor.Services interface.
|
||||
// Reads the active context's host so PingBaseURL targets the user's actual
|
||||
// Reads the active profile's host so PingBaseURL targets the user's actual
|
||||
// server, not localhost.
|
||||
//
|
||||
// Critically: this does NOT pre-resolve f.Client(). doctor's package promise
|
||||
@@ -388,7 +388,7 @@ func buildServices(f *cmdutil.Factory) (Services, error) {
|
||||
return nil, err
|
||||
}
|
||||
host := ""
|
||||
if ctx, ok := cfg.Contexts[cfg.CurrentContext]; ok {
|
||||
if ctx, ok := cfg.Profiles[cfg.CurrentProfile]; ok {
|
||||
host = ctx.Host
|
||||
}
|
||||
// WEKNORA_BASE_URL still wins as a test/dev override; production reads host.
|
||||
@@ -409,7 +409,7 @@ const pingTimeout = 5 * time.Second
|
||||
|
||||
func (s *realServices) PingBaseURL(ctx context.Context) error {
|
||||
if s.host == "" {
|
||||
return fmt.Errorf("no host configured for active context")
|
||||
return fmt.Errorf("no host configured for active profile")
|
||||
}
|
||||
url := s.host + "/health"
|
||||
ctx, cancel := context.WithTimeout(ctx, pingTimeout)
|
||||
@@ -429,7 +429,7 @@ func (s *realServices) PingBaseURL(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCurrentUser lazily resolves the SDK client. When no context is configured
|
||||
// GetCurrentUser lazily resolves the SDK client. When no profile is configured
|
||||
// or credentials missing, f.Client() returns auth.unauthenticated; we surface
|
||||
// that as the auth_credential check's failure rather than aborting doctor.
|
||||
func (s *realServices) GetCurrentUser(ctx context.Context) (*sdk.CurrentUserResponse, error) {
|
||||
|
||||
@@ -48,8 +48,8 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &ListOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List knowledge bases visible to the active context",
|
||||
Long: `List knowledge bases visible to the active context, sorted by most recently updated. Pass --pinned to restrict to pinned KBs.`,
|
||||
Short: "List knowledge bases visible to the active profile",
|
||||
Long: `List knowledge bases visible to the active profile, sorted by most recently updated. Pass --pinned to restrict to pinned KBs.`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(c *cobra.Command, _ []string) error {
|
||||
fopts, err := cmdutil.CheckFormatFlag(c)
|
||||
|
||||
@@ -123,10 +123,10 @@ func resolveProfile(f *cmdutil.Factory) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if cfg.CurrentContext == "" {
|
||||
if cfg.CurrentProfile == "" {
|
||||
return "", cmdutil.NewError(cmdutil.CodeAuthUnauthenticated, "no active profile; run `weknora auth login` first")
|
||||
}
|
||||
return cfg.CurrentContext, nil
|
||||
return cfg.CurrentProfile, nil
|
||||
}
|
||||
|
||||
// resolveKB resolves --kb to (kbID, kbName). Name is empty when the user
|
||||
|
||||
@@ -42,8 +42,8 @@ func fakeKBServer(t *testing.T, kbs []sdk.KnowledgeBase) *httptest.Server {
|
||||
|
||||
func newFactory(currentCtx string, client *sdk.Client) *cmdutil.Factory {
|
||||
cfg := &config.Config{
|
||||
CurrentContext: currentCtx,
|
||||
Contexts: map[string]config.Context{
|
||||
CurrentProfile: currentCtx,
|
||||
Profiles: map[string]config.Profile{
|
||||
currentCtx: {Host: "https://example"},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// the JSON-RPC 2.0 wire protocol agentic IDEs use to call external tools.
|
||||
// `weknora mcp serve` exposes a curated subset of the CLI's read surface
|
||||
// as MCP tools so an IDE-side agent can list / view / search / chat against
|
||||
// the user's active WeKnora context without shelling out to the CLI per
|
||||
// the user's active WeKnora profile without shelling out to the CLI per
|
||||
// call.
|
||||
//
|
||||
// Package name is `mcpcmd` to avoid shadowing `cli/internal/mcp` (the
|
||||
|
||||
@@ -16,8 +16,8 @@ func NewCmdServe(f *cmdutil.Factory) *cobra.Command {
|
||||
Long: `Speaks JSON-RPC 2.0 on stdin/stdout to an MCP client. Logs go to
|
||||
stderr; the data channel is reserved for protocol traffic.
|
||||
|
||||
Authentication is inherited from the active context (or --context). The
|
||||
server eagerly resolves the SDK client at startup - if no context is
|
||||
Authentication is inherited from the active profile (or --profile). The
|
||||
server eagerly resolves the SDK client at startup - if no profile is
|
||||
configured, the process exits with auth.unauthenticated before any MCP
|
||||
handshake. This way an IDE-side agent sees a clear failure mode rather
|
||||
than a server that handshakes successfully then errors on every tool.
|
||||
|
||||
@@ -2,7 +2,6 @@ package profilecmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
@@ -68,7 +67,7 @@ adds leave the current profile untouched.`,
|
||||
}
|
||||
|
||||
func runAdd(opts *AddOptions, fopts *cmdutil.FormatOptions, name string) error {
|
||||
if err := validateName(name); err != nil {
|
||||
if err := cmdutil.ValidateProfileName(name); err != nil {
|
||||
return err
|
||||
}
|
||||
host, err := cmdutil.NormalizeHost(opts.Host)
|
||||
@@ -80,20 +79,20 @@ func runAdd(opts *AddOptions, fopts *cmdutil.FormatOptions, name string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, exists := cfg.Contexts[name]; exists {
|
||||
if _, exists := cfg.Profiles[name]; exists {
|
||||
return &cmdutil.Error{
|
||||
Code: cmdutil.CodeResourceAlreadyExists,
|
||||
Message: fmt.Sprintf("profile %q already exists", name),
|
||||
Hint: fmt.Sprintf("use a different name, or run `weknora profile remove %s` first", name),
|
||||
}
|
||||
}
|
||||
if cfg.Contexts == nil {
|
||||
cfg.Contexts = map[string]config.Context{}
|
||||
if cfg.Profiles == nil {
|
||||
cfg.Profiles = map[string]config.Profile{}
|
||||
}
|
||||
cfg.Contexts[name] = config.Context{Host: host, User: opts.User}
|
||||
wasFirst := cfg.CurrentContext == ""
|
||||
cfg.Profiles[name] = config.Profile{Host: host, User: opts.User}
|
||||
wasFirst := cfg.CurrentProfile == ""
|
||||
if wasFirst {
|
||||
cfg.CurrentContext = name
|
||||
cfg.CurrentProfile = name
|
||||
}
|
||||
if err := config.Save(cfg); err != nil {
|
||||
return cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "save config")
|
||||
@@ -109,40 +108,3 @@ func runAdd(opts *AddOptions, fopts *cmdutil.FormatOptions, name string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateName enforces the allowlist advertised in the --help hint: letters,
|
||||
// digits, dash, underscore, dot. The `.` exception lets emails / DNS-like
|
||||
// names through; the path-traversal `..` is structurally rejected by a
|
||||
// separate guard because it would let a hand-edited config.yaml claim a
|
||||
// profile whose name walks out of the keyring namespace.
|
||||
func validateName(name string) error {
|
||||
if name == "" {
|
||||
return &cmdutil.Error{
|
||||
Code: cmdutil.CodeInputInvalidArgument,
|
||||
Message: "profile name must not be empty",
|
||||
}
|
||||
}
|
||||
if name == "." || name == ".." || strings.Contains(name, "/") || strings.Contains(name, "\\") {
|
||||
return &cmdutil.Error{
|
||||
Code: cmdutil.CodeInputInvalidArgument,
|
||||
Message: fmt.Sprintf("profile name %q is reserved or path-like", name),
|
||||
Hint: "use letters, digits, dashes, underscores, or dots",
|
||||
}
|
||||
}
|
||||
for _, r := range name {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z',
|
||||
r >= 'A' && r <= 'Z',
|
||||
r >= '0' && r <= '9',
|
||||
r == '-' || r == '_' || r == '.':
|
||||
continue
|
||||
default:
|
||||
return &cmdutil.Error{
|
||||
Code: cmdutil.CodeInputInvalidArgument,
|
||||
Message: fmt.Sprintf("profile name %q contains invalid character %q", name, r),
|
||||
Hint: "use letters, digits, dashes, underscores, or dots",
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -22,9 +22,9 @@ func TestAdd_HappyPath(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
c, ok := cfg.Contexts["staging"]
|
||||
c, ok := cfg.Profiles["staging"]
|
||||
if !ok {
|
||||
t.Fatalf("staging not in Contexts; got keys=%v", profileKeys(cfg.Contexts))
|
||||
t.Fatalf("staging not in Profiles; got keys=%v", profileKeys(cfg.Profiles))
|
||||
}
|
||||
if c.Host != "https://my.example.com" {
|
||||
t.Errorf("Host=%q, want https://my.example.com", c.Host)
|
||||
@@ -33,8 +33,8 @@ func TestAdd_HappyPath(t *testing.T) {
|
||||
t.Errorf("User=%q, want alice@example.com", c.User)
|
||||
}
|
||||
// First profile auto-becomes current.
|
||||
if cfg.CurrentContext != "staging" {
|
||||
t.Errorf("first profile should auto-become current, got CurrentContext=%q", cfg.CurrentContext)
|
||||
if cfg.CurrentProfile != "staging" {
|
||||
t.Errorf("first profile should auto-become current, got CurrentProfile=%q", cfg.CurrentProfile)
|
||||
}
|
||||
if !strings.Contains(out.String(), "staging") {
|
||||
t.Errorf("output should mention added name, got %q", out.String())
|
||||
@@ -46,8 +46,8 @@ func TestAdd_DuplicateName(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
|
||||
cfg := &config.Config{
|
||||
CurrentContext: "staging",
|
||||
Contexts: map[string]config.Context{"staging": {Host: "https://old.example.com"}},
|
||||
CurrentProfile: "staging",
|
||||
Profiles: map[string]config.Profile{"staging": {Host: "https://old.example.com"}},
|
||||
}
|
||||
if err := config.Save(cfg); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
@@ -66,8 +66,8 @@ func TestAdd_DuplicateName(t *testing.T) {
|
||||
}
|
||||
// Existing entry must NOT be overwritten.
|
||||
got, _ := config.Load()
|
||||
if got.Contexts["staging"].Host != "https://old.example.com" {
|
||||
t.Errorf("existing profile overwritten; Host=%q", got.Contexts["staging"].Host)
|
||||
if got.Profiles["staging"].Host != "https://old.example.com" {
|
||||
t.Errorf("existing profile overwritten; Host=%q", got.Profiles["staging"].Host)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,8 +103,8 @@ func TestAdd_SecondProfileDoesNotChangeCurrent(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
|
||||
cfg := &config.Config{
|
||||
CurrentContext: "production",
|
||||
Contexts: map[string]config.Context{"production": {Host: "https://prod.example.com"}},
|
||||
CurrentProfile: "production",
|
||||
Profiles: map[string]config.Profile{"production": {Host: "https://prod.example.com"}},
|
||||
}
|
||||
if err := config.Save(cfg); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
@@ -114,8 +114,8 @@ func TestAdd_SecondProfileDoesNotChangeCurrent(t *testing.T) {
|
||||
t.Fatalf("runAdd: %v", err)
|
||||
}
|
||||
got, _ := config.Load()
|
||||
if got.CurrentContext != "production" {
|
||||
t.Errorf("adding a second profile must not switch current; got %q", got.CurrentContext)
|
||||
if got.CurrentProfile != "production" {
|
||||
t.Errorf("adding a second profile must not switch current; got %q", got.CurrentProfile)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -61,13 +61,13 @@ func runList(fopts *cmdutil.FormatOptions) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entries := make([]listEntry, 0, len(cfg.Contexts))
|
||||
for name, c := range cfg.Contexts {
|
||||
entries := make([]listEntry, 0, len(cfg.Profiles))
|
||||
for name, c := range cfg.Profiles {
|
||||
entries = append(entries, listEntry{
|
||||
Name: name,
|
||||
Host: c.Host,
|
||||
User: c.User,
|
||||
Current: name == cfg.CurrentContext,
|
||||
Current: name == cfg.CurrentProfile,
|
||||
})
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool { return entries[i].Name < entries[j].Name })
|
||||
|
||||
@@ -27,8 +27,8 @@ func TestList_MultipleSorted(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
|
||||
cfg := &config.Config{
|
||||
CurrentContext: "staging",
|
||||
Contexts: map[string]config.Context{
|
||||
CurrentProfile: "staging",
|
||||
Profiles: map[string]config.Profile{
|
||||
"production": {Host: "https://prod.example.com", User: "alice@example.com"},
|
||||
"staging": {Host: "https://staging.example.com"},
|
||||
"alpha": {Host: "https://alpha.example.com"},
|
||||
@@ -65,8 +65,8 @@ func TestList_JSON(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
|
||||
cfg := &config.Config{
|
||||
CurrentContext: "staging",
|
||||
Contexts: map[string]config.Context{
|
||||
CurrentProfile: "staging",
|
||||
Profiles: map[string]config.Profile{
|
||||
"staging": {Host: "https://staging.example.com", User: "bob@example.com"},
|
||||
"production": {Host: "https://prod.example.com"},
|
||||
},
|
||||
|
||||
@@ -42,7 +42,7 @@ func NewCmdRemove(f *cmdutil.Factory) *cobra.Command {
|
||||
Long: `Deletes the named profile from config.yaml and best-effort clears any
|
||||
keyring references it owned (matches ` + "`weknora auth logout`" + `).
|
||||
|
||||
Removing the current profile also clears CurrentContext - subsequent commands
|
||||
Removing the current profile also clears CurrentProfile - subsequent commands
|
||||
will error until you select another with ` + "`weknora profile use <name>`" + ` or pick
|
||||
one up via the global ` + "`--profile`" + ` flag. Because that change is observable in
|
||||
every later command, removing the current profile requires explicit -y/--yes
|
||||
@@ -84,11 +84,11 @@ func runRemove(opts *RemoveOptions, fopts *cmdutil.FormatOptions, name string, s
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, exists := cfg.Contexts[name]
|
||||
ctx, exists := cfg.Profiles[name]
|
||||
if !exists {
|
||||
return notFoundError(name, cfg)
|
||||
}
|
||||
wasCurrent := name == cfg.CurrentContext
|
||||
wasCurrent := name == cfg.CurrentProfile
|
||||
|
||||
jsonOut := fopts.WantsJSON()
|
||||
// Confirmation only fires for removing the current profile - non-current
|
||||
@@ -101,9 +101,9 @@ func runRemove(opts *RemoveOptions, fopts *cmdutil.FormatOptions, name string, s
|
||||
|
||||
// Config first, secrets after: a crash in between leaves an orphan
|
||||
// keyring entry but no dangling config ref (same ordering as auth logout).
|
||||
delete(cfg.Contexts, name)
|
||||
delete(cfg.Profiles, name)
|
||||
if wasCurrent {
|
||||
cfg.CurrentContext = ""
|
||||
cfg.CurrentProfile = ""
|
||||
}
|
||||
if err := config.Save(cfg); err != nil {
|
||||
return cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "save config")
|
||||
@@ -125,7 +125,7 @@ func runRemove(opts *RemoveOptions, fopts *cmdutil.FormatOptions, name string, s
|
||||
// clearProfileSecrets mirrors auth/logout.go: best-effort delete every secret
|
||||
// slot the profile references. Errors are swallowed so a missing keyring
|
||||
// entry doesn't block remove (same policy as `auth logout`).
|
||||
func clearProfileSecrets(store secrets.Store, c config.Context, name string) {
|
||||
func clearProfileSecrets(store secrets.Store, c config.Profile, name string) {
|
||||
if c.TokenRef != "" {
|
||||
_ = store.Delete(name, "access")
|
||||
}
|
||||
|
||||
@@ -38,8 +38,8 @@ func TestRemove_NonCurrent_NoPromptNeeded(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
|
||||
cfg := &config.Config{
|
||||
CurrentContext: "production",
|
||||
Contexts: map[string]config.Context{
|
||||
CurrentProfile: "production",
|
||||
Profiles: map[string]config.Profile{
|
||||
"production": {Host: "https://prod.example.com", TokenRef: "mem://production/access"},
|
||||
"staging": {Host: "https://staging.example.com", APIKeyRef: "mem://staging/api_key"},
|
||||
},
|
||||
@@ -58,11 +58,11 @@ func TestRemove_NonCurrent_NoPromptNeeded(t *testing.T) {
|
||||
}
|
||||
|
||||
got, _ := config.Load()
|
||||
if _, exists := got.Contexts["staging"]; exists {
|
||||
t.Errorf("staging should have been removed; Contexts=%v", got.Contexts)
|
||||
if _, exists := got.Profiles["staging"]; exists {
|
||||
t.Errorf("staging should have been removed; Profiles=%v", got.Profiles)
|
||||
}
|
||||
if got.CurrentContext != "production" {
|
||||
t.Errorf("CurrentContext must be unchanged, got %q", got.CurrentContext)
|
||||
if got.CurrentProfile != "production" {
|
||||
t.Errorf("CurrentProfile must be unchanged, got %q", got.CurrentProfile)
|
||||
}
|
||||
assertDeleted(t, store, "staging", "api_key")
|
||||
if !strings.Contains(out.String(), "staging") {
|
||||
@@ -74,7 +74,7 @@ func TestRemove_NotFound_WithDidYouMean(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
|
||||
cfg := &config.Config{Contexts: map[string]config.Context{
|
||||
cfg := &config.Config{Profiles: map[string]config.Profile{
|
||||
"production": {Host: "https://prod"},
|
||||
"staging": {Host: "https://staging"},
|
||||
}}
|
||||
@@ -103,8 +103,8 @@ func TestRemove_Current_NonTTY_NoYes_RequiresConfirmation(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
|
||||
cfg := &config.Config{
|
||||
CurrentContext: "production",
|
||||
Contexts: map[string]config.Context{
|
||||
CurrentProfile: "production",
|
||||
Profiles: map[string]config.Profile{
|
||||
"production": {Host: "https://prod", TokenRef: "mem://production/access"},
|
||||
"staging": {Host: "https://staging"},
|
||||
},
|
||||
@@ -129,8 +129,8 @@ func TestRemove_Current_NonTTY_NoYes_RequiresConfirmation(t *testing.T) {
|
||||
t.Errorf("expected exit-10, got %d", cmdutil.ExitCode(err))
|
||||
}
|
||||
// Must not have mutated config or keyring.
|
||||
if got, _ := config.Load(); got.CurrentContext != "production" {
|
||||
t.Errorf("config mutated despite confirmation gate: CurrentContext=%q", got.CurrentContext)
|
||||
if got, _ := config.Load(); got.CurrentProfile != "production" {
|
||||
t.Errorf("config mutated despite confirmation gate: CurrentProfile=%q", got.CurrentProfile)
|
||||
}
|
||||
if v, err := store.Get("production", "access"); err != nil || v == "" {
|
||||
t.Errorf("keyring touched before confirmation, get=%q err=%v", v, err)
|
||||
@@ -142,8 +142,8 @@ func TestRemove_Current_WithYes_ClearsCurrent(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
|
||||
cfg := &config.Config{
|
||||
CurrentContext: "production",
|
||||
Contexts: map[string]config.Context{
|
||||
CurrentProfile: "production",
|
||||
Profiles: map[string]config.Profile{
|
||||
"production": {Host: "https://prod", TokenRef: "mem://production/access"},
|
||||
"staging": {Host: "https://staging"},
|
||||
},
|
||||
@@ -157,11 +157,11 @@ func TestRemove_Current_WithYes_ClearsCurrent(t *testing.T) {
|
||||
t.Fatalf("runRemove: %v", err)
|
||||
}
|
||||
got, _ := config.Load()
|
||||
if _, exists := got.Contexts["production"]; exists {
|
||||
if _, exists := got.Profiles["production"]; exists {
|
||||
t.Errorf("production should be removed")
|
||||
}
|
||||
if got.CurrentContext != "" {
|
||||
t.Errorf("removing current must clear CurrentContext, got %q", got.CurrentContext)
|
||||
if got.CurrentProfile != "" {
|
||||
t.Errorf("removing current must clear CurrentProfile, got %q", got.CurrentProfile)
|
||||
}
|
||||
assertDeleted(t, store, "production", "access")
|
||||
if !strings.Contains(out.String(), "current profile cleared") {
|
||||
@@ -174,8 +174,8 @@ func TestRemove_Current_TTY_PromptNo(t *testing.T) {
|
||||
_, errBuf := iostreams.SetForTestWithTTY(t)
|
||||
|
||||
cfg := &config.Config{
|
||||
CurrentContext: "production",
|
||||
Contexts: map[string]config.Context{"production": {Host: "https://prod"}},
|
||||
CurrentProfile: "production",
|
||||
Profiles: map[string]config.Profile{"production": {Host: "https://prod"}},
|
||||
}
|
||||
if err := config.Save(cfg); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
@@ -199,7 +199,7 @@ func TestRemove_Current_TTY_PromptNo(t *testing.T) {
|
||||
if !strings.Contains(errBuf.String(), "Aborted") {
|
||||
t.Errorf("stderr should contain Aborted, got %q", errBuf.String())
|
||||
}
|
||||
if got, _ := config.Load(); got.CurrentContext != "production" {
|
||||
if got, _ := config.Load(); got.CurrentProfile != "production" {
|
||||
t.Errorf("aborted remove must not mutate config")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,11 +56,11 @@ func runUse(name string, fopts *cmdutil.FormatOptions) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, ok := cfg.Contexts[name]; !ok {
|
||||
if _, ok := cfg.Profiles[name]; !ok {
|
||||
return notFoundError(name, cfg)
|
||||
}
|
||||
prev := cfg.CurrentContext
|
||||
cfg.CurrentContext = name
|
||||
prev := cfg.CurrentProfile
|
||||
cfg.CurrentProfile = name
|
||||
if err := config.Save(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -77,14 +77,14 @@ func runUse(name string, fopts *cmdutil.FormatOptions) error {
|
||||
}
|
||||
|
||||
func notFoundError(name string, cfg *config.Config) error {
|
||||
if len(cfg.Contexts) == 0 {
|
||||
if len(cfg.Profiles) == 0 {
|
||||
return &cmdutil.Error{
|
||||
Code: cmdutil.CodeLocalProfileNotFound,
|
||||
Message: fmt.Sprintf("profile not found: %s", name),
|
||||
Hint: "no profiles registered - run `weknora auth login` first",
|
||||
}
|
||||
}
|
||||
keys := profileKeys(cfg.Contexts)
|
||||
keys := profileKeys(cfg.Profiles)
|
||||
candidate := closestMatch(name, keys)
|
||||
var hint string
|
||||
if candidate != "" && candidate != name {
|
||||
@@ -99,7 +99,7 @@ func notFoundError(name string, cfg *config.Config) error {
|
||||
}
|
||||
}
|
||||
|
||||
func profileKeys(m map[string]config.Context) []string {
|
||||
func profileKeys(m map[string]config.Profile) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
|
||||
@@ -14,8 +14,8 @@ func TestUse_OK(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
|
||||
cfg := &config.Config{
|
||||
CurrentContext: "staging",
|
||||
Contexts: map[string]config.Context{
|
||||
CurrentProfile: "staging",
|
||||
Profiles: map[string]config.Profile{
|
||||
"staging": {Host: "https://staging.example.com"},
|
||||
"production": {Host: "https://prod.example.com"},
|
||||
},
|
||||
@@ -32,8 +32,8 @@ func TestUse_OK(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
if got.CurrentContext != "production" {
|
||||
t.Errorf("CurrentContext = %q, want production", got.CurrentContext)
|
||||
if got.CurrentProfile != "production" {
|
||||
t.Errorf("CurrentProfile = %q, want production", got.CurrentProfile)
|
||||
}
|
||||
if !strings.Contains(out.String(), "production") {
|
||||
t.Errorf("output should mention switched-to profile, got %q", out.String())
|
||||
@@ -44,7 +44,7 @@ func TestUse_NotFound_WithDidYouMean(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
|
||||
cfg := &config.Config{Contexts: map[string]config.Context{
|
||||
cfg := &config.Config{Profiles: map[string]config.Profile{
|
||||
"production": {Host: "https://prod"},
|
||||
"staging": {Host: "https://staging"},
|
||||
}}
|
||||
@@ -74,7 +74,7 @@ func TestUse_NotFound_WithDidYouMean(t *testing.T) {
|
||||
func TestUse_NotFound_DeterministicTieBreak(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
cfg := &config.Config{Contexts: map[string]config.Context{
|
||||
cfg := &config.Config{Profiles: map[string]config.Profile{
|
||||
"prod": {Host: "https://a"},
|
||||
"prom": {Host: "https://b"},
|
||||
"prog": {Host: "https://c"},
|
||||
@@ -113,7 +113,7 @@ func TestUse_CaseSensitive(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
|
||||
cfg := &config.Config{Contexts: map[string]config.Context{
|
||||
cfg := &config.Config{Profiles: map[string]config.Profile{
|
||||
"Production": {Host: "https://prod"},
|
||||
}}
|
||||
_ = config.Save(cfg)
|
||||
|
||||
@@ -40,7 +40,7 @@ type KBSearchService interface {
|
||||
}
|
||||
|
||||
// NewCmdKB builds `weknora search kb "<query>"` - substring + case-insensitive
|
||||
// match across KB names and descriptions visible to the active context.
|
||||
// match across KB names and descriptions visible to the active profile.
|
||||
// Results are sorted by name length (shortest first; usually the closest
|
||||
// hit) for deterministic output.
|
||||
func NewCmdKB(f *cmdutil.Factory) *cobra.Command {
|
||||
@@ -49,7 +49,7 @@ func NewCmdKB(f *cmdutil.Factory) *cobra.Command {
|
||||
Use: `kb "<query>"`,
|
||||
Short: "Find knowledge bases by name or description (client-side substring match)",
|
||||
Long: `Substring + case-insensitive match across KB names and descriptions visible
|
||||
to the active context. Results are sorted by name length (shortest first;
|
||||
to the active profile. Results are sorted by name length (shortest first;
|
||||
usually the closest hit) for deterministic output.
|
||||
|
||||
This is name-discovery only - for searching *inside* a knowledge base's
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
|
||||
// sessionAskFields enumerates the NDJSON init-event fields surfaced for
|
||||
// `--format json` / `--format ndjson` discovery on `session ask`. Reflects
|
||||
// the InitEvent head line + the raw SDK agent event vocabulary (§5).
|
||||
// the InitEvent head line + the raw SDK agent event vocabulary.
|
||||
var sessionAskFields = []string{
|
||||
"session_id", "agent_id",
|
||||
// SDK event fields (pass-through): response_type, content, done,
|
||||
@@ -65,7 +65,7 @@ Modes:
|
||||
one init line at head (session_id, agent_id),
|
||||
then raw SDK agent events verbatim. Both
|
||||
json and ndjson flags produce the same
|
||||
NDJSON stream (§5).`,
|
||||
NDJSON stream.`,
|
||||
Example: ` weknora session ask --agent ag_x "Summarize Q3 sales"
|
||||
weknora session ask --session sess_x --agent ag_x "Follow-up question"
|
||||
weknora session ask --agent ag_x "Multi-step task" --format ndjson`,
|
||||
@@ -113,7 +113,7 @@ func runAsk(ctx context.Context, opts *AskOptions, fopts *cmdutil.FormatOptions,
|
||||
|
||||
// Streaming commands route --format json AND --format ndjson to the
|
||||
// NDJSON event-stream path. A buffered envelope makes no sense for a
|
||||
// streaming command (§5). Only --format text uses the live renderer.
|
||||
// streaming command. Only --format text uses the live renderer.
|
||||
ndjsonMode := fopts != nil && (fopts.Mode == cmdutil.FormatJSON || fopts.Mode == cmdutil.FormatNDJSON)
|
||||
|
||||
sessionID := opts.SessionID
|
||||
@@ -150,11 +150,11 @@ func runAsk(ctx context.Context, opts *AskOptions, fopts *cmdutil.FormatOptions,
|
||||
|
||||
// runAskNDJSON handles --format json and --format ndjson paths.
|
||||
// Emits a CLI init event at stream head, then passes every SDK agent event
|
||||
// through verbatim as NDJSON lines. No buffering (§5).
|
||||
// through verbatim as NDJSON lines. No buffering.
|
||||
func runAskNDJSON(ctx context.Context, opts *AskOptions, sessionID string, svc AskService) error {
|
||||
w := iostreams.IO.Out
|
||||
|
||||
// 1. Inject the CLI-managed init event at the head of the stream (§5.3).
|
||||
// 1. Inject the CLI-managed init event at the head of the stream.
|
||||
// Carries session pointer + agent id callers need for follow-up threading.
|
||||
initEv := output.InitEvent{
|
||||
SessionID: sessionID,
|
||||
|
||||
@@ -70,7 +70,7 @@ func textOpts() *cmdutil.FormatOptions {
|
||||
}
|
||||
|
||||
// ndjsonOpts returns a FormatOptions for the NDJSON event-stream path.
|
||||
// --format json routes here too (§5).
|
||||
// --format json routes here too for streaming commands.
|
||||
func ndjsonOpts() *cmdutil.FormatOptions {
|
||||
return &cmdutil.FormatOptions{Mode: cmdutil.FormatNDJSON}
|
||||
}
|
||||
@@ -153,7 +153,7 @@ func TestSessionAsk_NDJSON_PassthroughEvents(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestSessionAsk_NDJSON_JSONEqualsNDJSON verifies that --format json and --format ndjson
|
||||
// produce identical NDJSON streams for session ask (§5).
|
||||
// produce identical NDJSON streams for session ask.
|
||||
func TestSessionAsk_NDJSON_JSONEqualsNDJSON(t *testing.T) {
|
||||
events := []*sdk.AgentStreamResponse{
|
||||
answerEvent("hello"),
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
|
||||
// sessionDeleteFields enumerates the fields surfaced for `--format json`
|
||||
// discovery on `session delete`. Tracks the single-id result struct;
|
||||
// multi-id mode emits batch envelope (§4.5).
|
||||
// multi-id mode emits the batch envelope.
|
||||
var sessionDeleteFields = []string{"id", "deleted"}
|
||||
|
||||
type DeleteOptions struct {
|
||||
|
||||
@@ -156,7 +156,7 @@ func TestMultiDelete_NonTTY_NoYes_RequiresConfirmation(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestSessionDelete_MultiID_PartialFailure_BatchEnvelope verifies that the JSON
|
||||
// output for a multi-id partial failure uses the §4.5 batch envelope shape:
|
||||
// output for a multi-id partial failure uses the batch envelope shape:
|
||||
// {ok:false, data:[{id,ok,result?|error?}...], meta:{count,successes,failures}}.
|
||||
func TestSessionDelete_MultiID_PartialFailure_BatchEnvelope(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
|
||||
@@ -49,7 +49,7 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &ListOptions{PageSize: defaultPageSize}
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List chat sessions for the active context",
|
||||
Short: "List chat sessions for the active profile",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(c *cobra.Command, _ []string) error {
|
||||
fopts, err := cmdutil.CheckFormatFlag(c)
|
||||
|
||||
@@ -14,7 +14,7 @@ type Refresher interface {
|
||||
RefreshToken(ctx context.Context, refreshToken string) (*sdk.RefreshTokenResponse, error)
|
||||
}
|
||||
|
||||
// RefreshAndPersist reads the stored refresh token for ctxName, exchanges it
|
||||
// RefreshAndPersist reads the stored refresh token for profileName, exchanges it
|
||||
// for a new access + refresh pair via refresher, and writes both back to the
|
||||
// secrets store. Returns the new access token (the refresh is already
|
||||
// persisted as a side-effect, so callers only need the access value to
|
||||
@@ -23,12 +23,12 @@ type Refresher interface {
|
||||
// Single canonical implementation shared by `weknora auth refresh` and the
|
||||
// AuthRetryTransport's refresh closure - both used to inline the same
|
||||
// six-step sequence with subtly diverging error wording.
|
||||
func RefreshAndPersist(ctx context.Context, store secrets.Store, refresher Refresher, ctxName string) (string, error) {
|
||||
refresh, err := store.Get(ctxName, "refresh")
|
||||
func RefreshAndPersist(ctx context.Context, store secrets.Store, refresher Refresher, profileName string) (string, error) {
|
||||
refresh, err := store.Get(profileName, "refresh")
|
||||
if errors.Is(err, secrets.ErrNotFound) || refresh == "" {
|
||||
return "", &Error{
|
||||
Code: CodeAuthTokenExpired,
|
||||
Message: "refresh token missing for context " + ctxName,
|
||||
Message: "refresh token missing for profile " + profileName,
|
||||
Hint: "run `weknora auth login` to re-authenticate",
|
||||
}
|
||||
}
|
||||
@@ -55,10 +55,10 @@ func RefreshAndPersist(ctx context.Context, store secrets.Store, refresher Refre
|
||||
}
|
||||
}
|
||||
|
||||
if err := store.Set(ctxName, "access", resp.AccessToken); err != nil {
|
||||
if err := store.Set(profileName, "access", resp.AccessToken); err != nil {
|
||||
return "", Wrapf(CodeLocalKeychainDenied, err, "save access token")
|
||||
}
|
||||
if err := store.Set(ctxName, "refresh", resp.RefreshToken); err != nil {
|
||||
if err := store.Set(profileName, "refresh", resp.RefreshToken); err != nil {
|
||||
return "", Wrapf(CodeLocalKeychainDenied, err, "save refresh token")
|
||||
}
|
||||
return resp.AccessToken, nil
|
||||
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
// - 2xx / 4xx / 5xx other than 401: pass through unchanged.
|
||||
// - 401 on /api/v1/auth/login or /api/v1/auth/refresh: pass through
|
||||
// (otherwise a stale refresh token causes infinite recursion).
|
||||
// - 401 with no initial token configured (api-key contexts): pass through
|
||||
// - 401 with no initial token configured (api-key profiles): pass through
|
||||
// - api-key credentials have no refresh semantic.
|
||||
// - 401 with non-replayable request body (req.GetBody == nil): pass
|
||||
// through. The SDK always uses bytes.Buffer bodies; this is a safety
|
||||
@@ -48,7 +48,7 @@ type AuthRetryTransport struct {
|
||||
// agrees with whatever the SDK was constructed with.
|
||||
//
|
||||
// Pass an empty initialToken to indicate "no bearer credential configured"
|
||||
// (e.g. an api-key context). In that mode the transport never invokes
|
||||
// (e.g. an api-key profile). In that mode the transport never invokes
|
||||
// refreshFn - a 401 is propagated as-is.
|
||||
func NewAuthRetryTransport(base http.RoundTripper, initialToken string, refreshFn func(context.Context) (string, error)) *AuthRetryTransport {
|
||||
if base == nil {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Package cmdutil — batch.go provides reusable plumbing for multi-target
|
||||
// mutations that emit the §4.5 batch envelope.
|
||||
// mutations that emit the batch envelope.
|
||||
//
|
||||
// Three pieces:
|
||||
// - BatchOutcome: per-target structured outcome (preserves argv order).
|
||||
@@ -56,7 +56,7 @@ func RunBatch(ctx context.Context, ids []string, op func(context.Context, string
|
||||
Code: CodeOperationFailed,
|
||||
Message: fmt.Sprintf("%d/%d operation(s) failed", failed, len(ids)),
|
||||
// Silent suppresses the stderr error envelope because the caller
|
||||
// already emitted the batch envelope to stdout (§4.5). The exit code
|
||||
// already emitted the batch envelope to stdout. The exit code
|
||||
// still propagates via Error.Code → ExitCode (falls through to 1).
|
||||
Silent: true,
|
||||
}
|
||||
@@ -65,7 +65,7 @@ func RunBatch(ctx context.Context, ids []string, op func(context.Context, string
|
||||
}
|
||||
|
||||
// EmitBatch writes the per-item outcomes per --format. JSON/NDJSON emit
|
||||
// the §4.5 batch envelope; text mode emits per-line "OK <id>" /
|
||||
// the batch envelope; text mode emits per-line "OK <id>" /
|
||||
// "FAIL <id>: <msg>".
|
||||
//
|
||||
// resultFn builds the per-item Result map for successes; nil ⇒ omit.
|
||||
|
||||
@@ -112,7 +112,7 @@ func TestRunBatch_ContextCancellation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmitBatch_JSON_Envelope verifies that the JSON path emits a valid §4.5
|
||||
// TestEmitBatch_JSON_Envelope verifies that the JSON path emits a valid
|
||||
// batch envelope with correct ok/error/result fields.
|
||||
func TestEmitBatch_JSON_Envelope(t *testing.T) {
|
||||
outcomes := []BatchOutcome{
|
||||
|
||||
@@ -152,8 +152,8 @@ func (e *Error) WithHint(hint string) *Error {
|
||||
return e
|
||||
}
|
||||
|
||||
// WithRetryCommand sets the directly-executable retry argv string.
|
||||
// Agent 端不用 regex 从 prose hint 提 argv。
|
||||
// WithRetryCommand sets the directly-executable retry argv string so agents
|
||||
// don't have to regex-extract argv from the prose Hint.
|
||||
// Empty string for codes without a canonical retry command.
|
||||
func (e *Error) WithRetryCommand(cmd string) *Error {
|
||||
e.RetryCommand = cmd
|
||||
|
||||
@@ -210,9 +210,10 @@ func defaultRetryCommand(code ErrorCode) string {
|
||||
case CodeNetworkError, CodeServerTimeout:
|
||||
return "weknora doctor"
|
||||
case CodeProjectLinkCorrupt:
|
||||
return "weknora link" // 重新绑定
|
||||
return "weknora link" // re-bind the project to a KB
|
||||
case CodeLocalConfigCorrupt:
|
||||
// 删 config + 重 login 是两步;prose hint 已说明,retry argv 留空
|
||||
// Recovery is two steps (delete config + re-login); the prose hint
|
||||
// already spells it out, so the retry argv stays empty.
|
||||
return ""
|
||||
}
|
||||
return ""
|
||||
|
||||
@@ -47,7 +47,7 @@ type Factory struct {
|
||||
Prompter func() prompt.Prompter
|
||||
Secrets func() (secrets.Store, error)
|
||||
|
||||
// ProfileOverride, if non-empty, replaces config.CurrentContext for this
|
||||
// ProfileOverride, if non-empty, replaces config.CurrentProfile for this
|
||||
// invocation only - set by the global --profile flag in PersistentPreRun.
|
||||
// Buildable Config() / Client() honor it without writing to disk.
|
||||
ProfileOverride string
|
||||
@@ -85,7 +85,7 @@ func New() *Factory {
|
||||
return nil, Wrapf(CodeLocalFileIO, err, "load config")
|
||||
}
|
||||
if f.ProfileOverride != "" {
|
||||
cfg.CurrentContext = f.ProfileOverride
|
||||
cfg.CurrentProfile = f.ProfileOverride
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
@@ -108,7 +108,7 @@ func New() *Factory {
|
||||
return f
|
||||
}
|
||||
|
||||
// buildClient resolves the active context, loads the credentials from secrets,
|
||||
// buildClient resolves the active profile, loads the credentials from secrets,
|
||||
// and constructs a *sdk.Client. Returns CodeAuthUnauthenticated when no
|
||||
// credentials are available so the user gets the right hint to run
|
||||
// `weknora auth login`.
|
||||
@@ -117,11 +117,11 @@ func buildClient(f *Factory) (*sdk.Client, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
profileName := cfg.CurrentContext
|
||||
profileName := cfg.CurrentProfile
|
||||
if profileName == "" {
|
||||
return nil, NewError(CodeAuthUnauthenticated, "no current profile configured; run `weknora auth login` to set one up")
|
||||
}
|
||||
ctx, ok := cfg.Contexts[profileName]
|
||||
prof, ok := cfg.Profiles[profileName]
|
||||
if !ok {
|
||||
// If the user explicitly overrode the profile (via --profile flag or
|
||||
// WEKNORA_PROFILE env), it's a bad argument - not a corrupt config file.
|
||||
@@ -132,11 +132,11 @@ func buildClient(f *Factory) (*sdk.Client, error) {
|
||||
WithHint("list available profiles with `weknora profile list`").
|
||||
WithRetryCommand("weknora profile list")
|
||||
}
|
||||
// ProfileOverride is empty: config.CurrentContext points at a missing entry.
|
||||
// ProfileOverride is empty: config.CurrentProfile points at a missing entry.
|
||||
// That's a genuinely corrupt config file.
|
||||
return nil, NewError(CodeLocalConfigCorrupt, fmt.Sprintf("config references unknown profile %q", profileName))
|
||||
}
|
||||
if ctx.Host == "" {
|
||||
if prof.Host == "" {
|
||||
return nil, NewError(CodeLocalConfigCorrupt, fmt.Sprintf("profile %q has no host", profileName))
|
||||
}
|
||||
|
||||
@@ -145,11 +145,11 @@ func buildClient(f *Factory) (*sdk.Client, error) {
|
||||
if err != nil {
|
||||
return nil, Wrapf(CodeLocalKeychainDenied, err, "init secrets store")
|
||||
}
|
||||
// Only fetch the secrets the context actually references. Skipping the
|
||||
// Only fetch the secrets the profile actually references. Skipping the
|
||||
// unused fetch avoids a `security` exec (macOS) / DBus call (Linux) per
|
||||
// authenticated invocation.
|
||||
var accessToken string
|
||||
if ctx.TokenRef != "" {
|
||||
if prof.TokenRef != "" {
|
||||
if access, err := LoadSecret(store, profileName, "access"); err != nil {
|
||||
return nil, err
|
||||
} else if access != "" {
|
||||
@@ -157,34 +157,41 @@ func buildClient(f *Factory) (*sdk.Client, error) {
|
||||
opts = append(opts, sdk.WithBearerToken(access))
|
||||
}
|
||||
}
|
||||
if ctx.APIKeyRef != "" {
|
||||
if prof.APIKeyRef != "" {
|
||||
if apiKey, err := LoadSecret(store, profileName, "api_key"); err != nil {
|
||||
return nil, err
|
||||
} else if apiKey != "" {
|
||||
opts = append(opts, sdk.WithAPIKey(apiKey))
|
||||
}
|
||||
}
|
||||
// JWT contexts (have both access + refresh refs) get the transparent
|
||||
// JWT profiles (have both access + refresh refs) get the transparent
|
||||
// 401-retry transport: on the first 401 from a non-/auth/* endpoint, the
|
||||
// transport reads the stored refresh token, calls /api/v1/auth/refresh,
|
||||
// persists the new pair, and replays the original request with the new
|
||||
// bearer. API-key contexts skip this (no refresh semantic) - a 401 from
|
||||
// bearer. API-key profiles skip this (no refresh semantic) - a 401 from
|
||||
// them propagates as auth.unauthenticated for the caller to handle.
|
||||
if ctx.TokenRef != "" && ctx.RefreshRef != "" {
|
||||
if prof.TokenRef != "" && prof.RefreshRef != "" {
|
||||
refreshFn := func(rctx context.Context) (string, error) {
|
||||
return refreshAccessToken(rctx, store, ctx.Host, profileName)
|
||||
return refreshAccessToken(rctx, store, prof.Host, profileName)
|
||||
}
|
||||
opts = append(opts, sdk.WithTransport(
|
||||
NewAuthRetryTransport(http.DefaultTransport, accessToken, refreshFn),
|
||||
))
|
||||
}
|
||||
// ctx.TenantID is intentionally NOT injected as X-Tenant-ID. Servers derive
|
||||
// prof.TenantID is intentionally NOT injected as X-Tenant-ID. Servers derive
|
||||
// tenant from the credential itself (JWT claim or API key prefix); the
|
||||
// header is only meaningful for explicit cross-tenant switching by users
|
||||
// with CanAccessAllTenants. Auto-mirroring the persisted tenant from config
|
||||
// breaks that contract - explicit cross-tenant flags would be required
|
||||
// before sending it. `tenant_id` stays in config for `auth status` display only.
|
||||
return sdk.NewClient(ctx.Host, opts...), nil
|
||||
return sdk.NewClient(prof.Host, opts...), nil
|
||||
}
|
||||
|
||||
// AddKBFlag registers the standard `--kb` flag that ResolveKB reads. Use this
|
||||
// instead of duplicating the flag declaration in every command that scopes to
|
||||
// a knowledge base — one source of truth for flag name and help text.
|
||||
func AddKBFlag(cmd *cobra.Command) {
|
||||
cmd.Flags().String("kb", "", "Knowledge base UUID or name (overrides env / project link)")
|
||||
}
|
||||
|
||||
// ResolveKB returns the active KB id for the running command, applying the
|
||||
@@ -245,13 +252,13 @@ func (f *Factory) ApplyLogLevel(cmd *cobra.Command, stderr io.Writer) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadSecret fetches a named secret for the given context from the keyring.
|
||||
// LoadSecret fetches a named secret for the given profile from the keyring.
|
||||
// Returns ("", nil) when the secret is absent (ErrNotFound); a real keyring
|
||||
// access failure surfaces as CodeLocalKeychainDenied. Used by buildClient
|
||||
// to assemble SDK auth options and by `auth token` to expose the raw
|
||||
// credential for shell scripting.
|
||||
func LoadSecret(store secrets.Store, context, key string) (string, error) {
|
||||
v, err := store.Get(context, key)
|
||||
func LoadSecret(store secrets.Store, profile, key string) (string, error) {
|
||||
v, err := store.Get(profile, key)
|
||||
if errors.Is(err, secrets.ErrNotFound) {
|
||||
return "", nil
|
||||
}
|
||||
@@ -273,7 +280,7 @@ func refreshAccessToken(ctx context.Context, store secrets.Store, host, profileN
|
||||
// ActiveProfile returns the resolved profile name for this invocation:
|
||||
// 1. ProfileOverride (set by --profile flag in root PersistentPreRunE)
|
||||
// 2. WEKNORA_PROFILE env var
|
||||
// 3. Config's CurrentContext (the active context / profile name)
|
||||
// 3. Config's CurrentProfile (the persisted active profile name)
|
||||
// 4. Empty string when nothing is configured (envelope omits the field).
|
||||
func (f *Factory) ActiveProfile() string {
|
||||
if f.ProfileOverride != "" {
|
||||
@@ -289,5 +296,5 @@ func (f *Factory) ActiveProfile() string {
|
||||
if err != nil || cfg == nil {
|
||||
return ""
|
||||
}
|
||||
return cfg.CurrentContext
|
||||
return cfg.CurrentProfile
|
||||
}
|
||||
|
||||
@@ -55,9 +55,9 @@ func TestFactory_Lazy(t *testing.T) {
|
||||
|
||||
// TestNew_FoundationDefaults verifies the production New() returns a usable
|
||||
// Factory and that Client surfaces auth.unauthenticated when no current
|
||||
// context is configured (the precondition for `weknora auth login`).
|
||||
// profile is configured (the precondition for `weknora auth login`).
|
||||
func TestNew_FoundationDefaults(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir()) // empty config → no current context
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir()) // empty config → no current profile
|
||||
f := New()
|
||||
require.NotNil(t, f)
|
||||
require.NotNil(t, f.Config)
|
||||
@@ -73,18 +73,18 @@ func TestNew_FoundationDefaults(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestFactory_ProfileOverride verifies the global --profile flag mechanism:
|
||||
// f.ProfileOverride replaces config.CurrentContext for this invocation only,
|
||||
// f.ProfileOverride replaces config.CurrentProfile for this invocation only,
|
||||
// without writing to disk.
|
||||
func TestFactory_ProfileOverride(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", dir)
|
||||
|
||||
// Seed config with two contexts; CurrentContext = "default"
|
||||
// Seed config with two profiles; CurrentProfile = "default"
|
||||
cfgPath := dir + "/weknora/config.yaml"
|
||||
require.NoError(t, os.MkdirAll(dir+"/weknora", 0o700))
|
||||
require.NoError(t, os.WriteFile(cfgPath, []byte(`
|
||||
current_context: default
|
||||
contexts:
|
||||
current_profile: default
|
||||
profiles:
|
||||
default:
|
||||
host: https://default.example
|
||||
other:
|
||||
@@ -93,25 +93,25 @@ contexts:
|
||||
|
||||
f := New()
|
||||
|
||||
t.Run("no override: returns CurrentContext from disk", func(t *testing.T) {
|
||||
t.Run("no override: returns CurrentProfile from disk", func(t *testing.T) {
|
||||
f.ProfileOverride = ""
|
||||
cfg, err := f.Config()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "default", cfg.CurrentContext)
|
||||
assert.Equal(t, "default", cfg.CurrentProfile)
|
||||
})
|
||||
|
||||
t.Run("override applied: ProfileOverride wins over disk", func(t *testing.T) {
|
||||
f.ProfileOverride = "other"
|
||||
cfg, err := f.Config()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "other", cfg.CurrentContext)
|
||||
assert.Equal(t, "other", cfg.CurrentProfile)
|
||||
})
|
||||
|
||||
t.Run("override does not persist to disk", func(t *testing.T) {
|
||||
// Reload from disk: should still be "default" (the original).
|
||||
raw, err := os.ReadFile(cfgPath)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(raw), "current_context: default")
|
||||
assert.Contains(t, string(raw), "current_profile: default")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@ func memSecretsFn(s *secrets.MemStore) func() (secrets.Store, error) {
|
||||
return func() (secrets.Store, error) { return s, nil }
|
||||
}
|
||||
|
||||
func TestBuildClient_NoCurrentContext(t *testing.T) {
|
||||
func TestBuildClient_NoCurrentProfile(t *testing.T) {
|
||||
f := &Factory{
|
||||
Config: func() (*config.Config, error) { return &config.Config{}, nil },
|
||||
Secrets: memSecretsFn(secrets.NewMemStore()),
|
||||
@@ -179,7 +179,7 @@ func TestBuildClient_NoCurrentContext(t *testing.T) {
|
||||
func TestBuildClient_UnknownContext(t *testing.T) {
|
||||
f := &Factory{
|
||||
Config: func() (*config.Config, error) {
|
||||
return &config.Config{CurrentContext: "ghost"}, nil
|
||||
return &config.Config{CurrentProfile: "ghost"}, nil
|
||||
},
|
||||
Secrets: memSecretsFn(secrets.NewMemStore()),
|
||||
}
|
||||
@@ -194,8 +194,8 @@ func TestBuildClient_MissingHost(t *testing.T) {
|
||||
f := &Factory{
|
||||
Config: func() (*config.Config, error) {
|
||||
return &config.Config{
|
||||
CurrentContext: "p",
|
||||
Contexts: map[string]config.Context{"p": {Host: ""}},
|
||||
CurrentProfile: "p",
|
||||
Profiles: map[string]config.Profile{"p": {Host: ""}},
|
||||
}, nil
|
||||
},
|
||||
Secrets: memSecretsFn(secrets.NewMemStore()),
|
||||
@@ -214,8 +214,8 @@ func TestBuildClient_HappyPath(t *testing.T) {
|
||||
f := &Factory{
|
||||
Config: func() (*config.Config, error) {
|
||||
return &config.Config{
|
||||
CurrentContext: "p",
|
||||
Contexts: map[string]config.Context{
|
||||
CurrentProfile: "p",
|
||||
Profiles: map[string]config.Profile{
|
||||
"p": {
|
||||
Host: "https://kb.example.com",
|
||||
TenantID: 7,
|
||||
@@ -233,15 +233,15 @@ func TestBuildClient_HappyPath(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBuildClient_SkipsUnreferencedSecrets(t *testing.T) {
|
||||
// If the context doesn't list APIKeyRef, buildClient must not call
|
||||
// If the profile doesn't list APIKeyRef, buildClient must not call
|
||||
// Get(api_key) - a perf invariant: avoid keychain trips for unused creds.
|
||||
store := &countingSecrets{MemStore: secrets.NewMemStore()}
|
||||
require.NoError(t, store.Set("p", "access", "jwt"))
|
||||
f := &Factory{
|
||||
Config: func() (*config.Config, error) {
|
||||
return &config.Config{
|
||||
CurrentContext: "p",
|
||||
Contexts: map[string]config.Context{
|
||||
CurrentProfile: "p",
|
||||
Profiles: map[string]config.Profile{
|
||||
"p": {Host: "https://x", TokenRef: "mem://p/access"},
|
||||
},
|
||||
}, nil
|
||||
|
||||
@@ -99,21 +99,14 @@ func (o *FormatOptions) WantsJSON() bool {
|
||||
// project with ".data[]", ".meta.count", etc.
|
||||
//
|
||||
// FormatNDJSON path: emits one bare JSON object per line (no envelope).
|
||||
// Matches spec §5 NDJSON event-passthrough semantics.
|
||||
// Matches the NDJSON event-passthrough contract used by streaming commands.
|
||||
//
|
||||
// FormatText path returns an error so a missed dispatch surfaces loudly.
|
||||
func (o *FormatOptions) Emit(w io.Writer, data any, meta *output.Meta) error {
|
||||
switch o.Mode {
|
||||
case FormatJSON:
|
||||
if o.JQ != "" {
|
||||
env := output.Envelope{
|
||||
OK: true,
|
||||
Data: data,
|
||||
Meta: meta,
|
||||
Notice: output.GetNotice(),
|
||||
Profile: globalProfile,
|
||||
}
|
||||
return format.WriteJSONFiltered(w, env, nil, o.JQ)
|
||||
return format.WriteJSONFiltered(w, output.NewEnvelope(data, meta, globalProfile), nil, o.JQ)
|
||||
}
|
||||
return output.WriteEnvelope(w, data, meta, o.TTY, globalProfile)
|
||||
case FormatNDJSON:
|
||||
|
||||
@@ -42,7 +42,7 @@ func TestEmit_WithMeta(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestEmit_NDJSON_NoEnvelope(t *testing.T) {
|
||||
// NDJSON path is event-passthrough; no envelope wrapping per spec §5.
|
||||
// NDJSON path is event-passthrough; no envelope wrapping.
|
||||
var buf bytes.Buffer
|
||||
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatNDJSON}
|
||||
data := []map[string]string{{"id": "a"}, {"id": "b"}}
|
||||
|
||||
48
cli/internal/cmdutil/profilename.go
Normal file
48
cli/internal/cmdutil/profilename.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ValidateProfileName enforces the profile-name allowlist: letters, digits,
|
||||
// dash, underscore, dot. The `.` exception lets email / DNS-like names
|
||||
// through; `.` / `..` and path separators are structurally rejected so a
|
||||
// hand-edited config.yaml can't claim a profile whose name walks out of the
|
||||
// keyring namespace.
|
||||
//
|
||||
// Crucially this also rejects shell metacharacters (space, `;`, `&`, `|`,
|
||||
// `$`, quotes, backticks, etc.), so the profile name remains safe to
|
||||
// interpolate into envelope.error.retry_command — an agent that exec's
|
||||
// `sh -c <retry_command>` cannot be tricked via a maliciously-named profile.
|
||||
func ValidateProfileName(name string) error {
|
||||
if name == "" {
|
||||
return &Error{
|
||||
Code: CodeInputInvalidArgument,
|
||||
Message: "profile name must not be empty",
|
||||
}
|
||||
}
|
||||
if name == "." || name == ".." || strings.Contains(name, "/") || strings.Contains(name, "\\") {
|
||||
return &Error{
|
||||
Code: CodeInputInvalidArgument,
|
||||
Message: fmt.Sprintf("profile name %q is reserved or path-like", name),
|
||||
Hint: "use letters, digits, dashes, underscores, or dots",
|
||||
}
|
||||
}
|
||||
for _, r := range name {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z',
|
||||
r >= 'A' && r <= 'Z',
|
||||
r >= '0' && r <= '9',
|
||||
r == '-' || r == '_' || r == '.':
|
||||
continue
|
||||
default:
|
||||
return &Error{
|
||||
Code: CodeInputInvalidArgument,
|
||||
Message: fmt.Sprintf("profile name %q contains invalid character %q", name, r),
|
||||
Hint: "use letters, digits, dashes, underscores, or dots",
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
113
cli/internal/cmdutil/profilename_test.go
Normal file
113
cli/internal/cmdutil/profilename_test.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestValidateProfileName_AcceptsAllowlist verifies the documented charset
|
||||
// is accepted as-is.
|
||||
func TestValidateProfileName_AcceptsAllowlist(t *testing.T) {
|
||||
for _, name := range []string{
|
||||
"default",
|
||||
"prod",
|
||||
"staging-2026",
|
||||
"ci_runner",
|
||||
"alice.example",
|
||||
"a", // single char
|
||||
"A-Z_0-9",
|
||||
} {
|
||||
if err := ValidateProfileName(name); err != nil {
|
||||
t.Errorf("ValidateProfileName(%q) unexpected error: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateProfileName_RejectsEmpty guards the empty-string base case.
|
||||
func TestValidateProfileName_RejectsEmpty(t *testing.T) {
|
||||
err := ValidateProfileName("")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty name")
|
||||
}
|
||||
var ce *Error
|
||||
if !errors.As(err, &ce) || ce.Code != CodeInputInvalidArgument {
|
||||
t.Errorf("expected input.invalid_argument, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateProfileName_RejectsShellMetachars is the security-critical
|
||||
// case: anything that could break retry_command shell interpolation must be
|
||||
// rejected at the entry point. If this test ever loosens, an agent that
|
||||
// exec()s retry_command becomes injectable via a malicious profile name.
|
||||
func TestValidateProfileName_RejectsShellMetachars(t *testing.T) {
|
||||
cases := []string{
|
||||
"evil; rm -rf /",
|
||||
"foo && bar",
|
||||
"foo || bar",
|
||||
"foo|bar",
|
||||
"foo`whoami`",
|
||||
"foo$(whoami)",
|
||||
"foo$bar",
|
||||
"foo>out",
|
||||
"foo<in",
|
||||
"foo bar", // space alone
|
||||
"foo'bar",
|
||||
`foo"bar`,
|
||||
"foo\nbar", // newline
|
||||
"foo\tbar", // tab
|
||||
"foo\\bar", // backslash (also path-like)
|
||||
"foo/bar", // slash (also path-like)
|
||||
"#foo", // comment marker
|
||||
"foo*bar", // glob
|
||||
"foo?bar", // glob
|
||||
"foo~bar", // home expansion
|
||||
"foo!bar", // history expansion
|
||||
"foo,bar", // brace-expansion-ish
|
||||
"foo[bar",
|
||||
}
|
||||
for _, name := range cases {
|
||||
err := ValidateProfileName(name)
|
||||
if err == nil {
|
||||
t.Errorf("ValidateProfileName(%q) should have rejected the name; an agent exec'ing retry_command would be injectable", name)
|
||||
continue
|
||||
}
|
||||
var ce *Error
|
||||
if !errors.As(err, &ce) || ce.Code != CodeInputInvalidArgument {
|
||||
t.Errorf("ValidateProfileName(%q) returned wrong code %v; want input.invalid_argument", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateProfileName_RejectsPathTraversal covers the keyring-namespace
|
||||
// escape vector. `.` and `..` are reserved, and any slash is rejected.
|
||||
func TestValidateProfileName_RejectsPathTraversal(t *testing.T) {
|
||||
for _, name := range []string{".", "..", "../foo", "foo/..", "a/b", `a\b`} {
|
||||
err := ValidateProfileName(name)
|
||||
if err == nil {
|
||||
t.Errorf("ValidateProfileName(%q) should have rejected the path-like name", name)
|
||||
continue
|
||||
}
|
||||
// Path-shaped names hit the dedicated "reserved or path-like" branch
|
||||
// first (clearer hint than "invalid character %q") for `..` and the
|
||||
// slashed forms.
|
||||
if name == "." || name == ".." || strings.ContainsAny(name, "/\\") {
|
||||
if !strings.Contains(err.Error(), "reserved or path-like") {
|
||||
t.Errorf("ValidateProfileName(%q): expected path-like hint, got %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateProfileName_HintMentionsAllowlist verifies the user-facing
|
||||
// hint actually tells them what's allowed (not just "invalid").
|
||||
func TestValidateProfileName_HintMentionsAllowlist(t *testing.T) {
|
||||
err := ValidateProfileName("foo bar")
|
||||
var ce *Error
|
||||
if !errors.As(err, &ce) {
|
||||
t.Fatalf("expected typed Error, got %v", err)
|
||||
}
|
||||
if !strings.Contains(ce.Hint, "letters") || !strings.Contains(ce.Hint, "dots") {
|
||||
t.Errorf("hint should describe allowlist; got %q", ce.Hint)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// Package config reads and writes the user-level config at
|
||||
// $XDG_CONFIG_HOME/weknora/config.yaml. yaml.v3 directly; viper is
|
||||
// intentionally not used. Multi-host context map lives here;
|
||||
// intentionally not used. Multi-host profile map lives here;
|
||||
// the per-project link (.weknora/project.yaml) is handled by the
|
||||
// projectlink package.
|
||||
package config
|
||||
@@ -18,8 +18,8 @@ import (
|
||||
// Config is the on-disk schema. Empty zero-value is valid (returned when the
|
||||
// file does not exist) so commands like --help / version don't fail.
|
||||
type Config struct {
|
||||
CurrentContext string `yaml:"current_context,omitempty"`
|
||||
Contexts map[string]Context `yaml:"contexts,omitempty"`
|
||||
CurrentProfile string `yaml:"current_profile,omitempty"`
|
||||
Profiles map[string]Profile `yaml:"profiles,omitempty"`
|
||||
|
||||
// Defaults holds CLI-wide defaults; fields opt-in.
|
||||
Defaults struct {
|
||||
@@ -29,8 +29,8 @@ type Config struct {
|
||||
} `yaml:"defaults,omitempty"`
|
||||
}
|
||||
|
||||
// Context is one named connection target (host + tenant + credential reference).
|
||||
type Context struct {
|
||||
// Profile is one named connection target (host + tenant + credential reference).
|
||||
type Profile struct {
|
||||
Host string `yaml:"host"`
|
||||
TenantID uint64 `yaml:"tenant_id,omitempty"`
|
||||
User string `yaml:"user,omitempty"`
|
||||
|
||||
@@ -18,14 +18,14 @@ func TestLoad_FileMissing(t *testing.T) {
|
||||
c, err := Load()
|
||||
require.NoError(t, err, "missing file must not error (commands like --help must work)")
|
||||
assert.NotNil(t, c)
|
||||
assert.Empty(t, c.Contexts)
|
||||
assert.Empty(t, c.Profiles)
|
||||
}
|
||||
|
||||
func TestSaveLoad_RoundTrip(t *testing.T) {
|
||||
testutil.XDGTempDir(t)
|
||||
in := &Config{
|
||||
CurrentContext: "prod",
|
||||
Contexts: map[string]Context{
|
||||
CurrentProfile: "prod",
|
||||
Profiles: map[string]Profile{
|
||||
"prod": {Host: "https://kb.example.com", TenantID: 7, APIKeyRef: "keychain://weknora/prod/access"},
|
||||
},
|
||||
}
|
||||
@@ -33,9 +33,9 @@ func TestSaveLoad_RoundTrip(t *testing.T) {
|
||||
|
||||
out, err := Load()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "prod", out.CurrentContext)
|
||||
assert.Equal(t, "https://kb.example.com", out.Contexts["prod"].Host)
|
||||
assert.Equal(t, uint64(7), out.Contexts["prod"].TenantID)
|
||||
assert.Equal(t, "prod", out.CurrentProfile)
|
||||
assert.Equal(t, "https://kb.example.com", out.Profiles["prod"].Host)
|
||||
assert.Equal(t, uint64(7), out.Profiles["prod"].TenantID)
|
||||
|
||||
p, err := Path()
|
||||
require.NoError(t, err)
|
||||
@@ -80,11 +80,11 @@ func TestPath_FallsBackToHome(t *testing.T) {
|
||||
|
||||
func TestSave_AtomicReplace(t *testing.T) {
|
||||
dir := testutil.XDGTempDir(t)
|
||||
require.NoError(t, Save(&Config{CurrentContext: "a"}))
|
||||
require.NoError(t, Save(&Config{CurrentContext: "b"}))
|
||||
require.NoError(t, Save(&Config{CurrentProfile: "a"}))
|
||||
require.NoError(t, Save(&Config{CurrentProfile: "b"}))
|
||||
out, err := Load()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "b", out.CurrentContext)
|
||||
assert.Equal(t, "b", out.CurrentProfile)
|
||||
matches, _ := filepath.Glob(filepath.Join(dir, "weknora", "*.tmp"))
|
||||
assert.Empty(t, matches)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ package format
|
||||
|
||||
// DashIfEmpty returns "-" for empty strings, otherwise s itself. Standard
|
||||
// rendering convention for human-readable table cells whose value is
|
||||
// optional (e.g. user email in `auth list` / `context list`).
|
||||
// optional (e.g. user email in `auth list` / `profile list`).
|
||||
func DashIfEmpty(s string) string {
|
||||
if s == "" {
|
||||
return "-"
|
||||
|
||||
@@ -2,7 +2,7 @@ package output
|
||||
|
||||
import "io"
|
||||
|
||||
// BatchItem is one per-id outcome in a batch operation envelope (§4.5).
|
||||
// BatchItem is one per-id outcome in a batch operation envelope.
|
||||
type BatchItem struct {
|
||||
ID string `json:"id"`
|
||||
OK bool `json:"ok"`
|
||||
@@ -10,7 +10,7 @@ type BatchItem struct {
|
||||
Error *ErrDetail `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// WriteBatchEnvelope writes a batch operation envelope per §4.5.
|
||||
// WriteBatchEnvelope writes a batch operation envelope.
|
||||
//
|
||||
// Wire shape: {ok, data:[BatchItem...], meta:{count, successes, failures}, profile?}.
|
||||
// Top-level ok = (failures == 0). Per-id ok reflects each item's outcome.
|
||||
|
||||
@@ -87,7 +87,8 @@ func TestWriteBatchEnvelope_AllFail(t *testing.T) {
|
||||
if !strings.Contains(got, `"ok":false`) {
|
||||
t.Errorf("expected ok:false; got %q", got)
|
||||
}
|
||||
// 全失败仍走 batch envelope shape,不退化到 ErrorEnvelope.
|
||||
// All-fail still uses the batch envelope shape; it does not degrade
|
||||
// to ErrorEnvelope.
|
||||
// An ErrorEnvelope has a top-level "error" key alongside "ok"; a batch
|
||||
// envelope has a top-level "data" array. Check that "data":[ is present.
|
||||
if !strings.Contains(got, `"data":[`) {
|
||||
|
||||
@@ -8,7 +8,8 @@ import (
|
||||
"io"
|
||||
)
|
||||
|
||||
// Envelope is the success-path stdout envelope (§4.1).
|
||||
// Envelope is the success-path stdout envelope. See AGENTS.md
|
||||
// "Stdout (success path)" for the full wire contract.
|
||||
type Envelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Data any `json:"data,omitempty"`
|
||||
@@ -17,14 +18,15 @@ type Envelope struct {
|
||||
Profile string `json:"profile,omitempty"`
|
||||
}
|
||||
|
||||
// ErrorEnvelope is the error-path stderr envelope (§4.2).
|
||||
// ErrorEnvelope is the error-path stderr envelope. See AGENTS.md
|
||||
// "Stderr (error path)" for the full wire contract.
|
||||
type ErrorEnvelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Error *ErrDetail `json:"error"`
|
||||
Notice map[string]any `json:"_notice,omitempty"`
|
||||
}
|
||||
|
||||
// Meta carries optional metadata in success envelopes (§4.3).
|
||||
// Meta carries optional metadata in success envelopes.
|
||||
type Meta struct {
|
||||
Count int `json:"count,omitempty"`
|
||||
HasMore bool `json:"has_more,omitempty"`
|
||||
@@ -38,7 +40,8 @@ type Meta struct {
|
||||
Failures *int `json:"failures,omitempty"` // batch ops
|
||||
}
|
||||
|
||||
// ErrDetail describes a structured error (§4.2).
|
||||
// ErrDetail describes a structured error. Embedded in ErrorEnvelope.Error
|
||||
// and also surfaced in batch envelope per-item failures.
|
||||
type ErrDetail struct {
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
@@ -49,7 +52,8 @@ type ErrDetail struct {
|
||||
Detail any `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// RiskDetail tags high-risk writes for agent protocol (§4.2 error.risk).
|
||||
// RiskDetail tags high-risk writes for the agent protocol. Surfaces in
|
||||
// error.risk on confirmation_required errors.
|
||||
// Level: only "destructive" is emitted; "read" / "write" slots reserved.
|
||||
type RiskDetail struct {
|
||||
Level string `json:"level"`
|
||||
@@ -69,20 +73,27 @@ func GetNotice() map[string]any {
|
||||
return PendingNotice()
|
||||
}
|
||||
|
||||
// WriteEnvelope writes a success envelope to w. Caller sets data + optional meta;
|
||||
// notice is injected from GetNotice() automatically.
|
||||
//
|
||||
// When profile is non-empty, the envelope includes a "profile" field.
|
||||
// indent: if true, output is multi-line (TTY mode); else compact (pipe mode).
|
||||
func WriteEnvelope(w io.Writer, data any, meta *Meta, indent bool, profile string) error {
|
||||
env := Envelope{
|
||||
// NewEnvelope assembles a success Envelope with the given data + optional
|
||||
// meta + profile, injecting the pending _notice automatically. Single source
|
||||
// of construction so callers that need the envelope value (e.g. jq filtering)
|
||||
// stay in sync with WriteEnvelope when fields are added.
|
||||
func NewEnvelope(data any, meta *Meta, profile string) Envelope {
|
||||
return Envelope{
|
||||
OK: true,
|
||||
Data: data,
|
||||
Meta: meta,
|
||||
Notice: GetNotice(),
|
||||
Profile: profile,
|
||||
}
|
||||
return writeJSON(w, env, indent)
|
||||
}
|
||||
|
||||
// WriteEnvelope writes a success envelope to w. Caller sets data + optional meta;
|
||||
// notice is injected from GetNotice() automatically.
|
||||
//
|
||||
// When profile is non-empty, the envelope includes a "profile" field.
|
||||
// indent: if true, output is multi-line (TTY mode); else compact (pipe mode).
|
||||
func WriteEnvelope(w io.Writer, data any, meta *Meta, indent bool, profile string) error {
|
||||
return writeJSON(w, NewEnvelope(data, meta, profile), indent)
|
||||
}
|
||||
|
||||
// WriteErrorEnvelope writes an error envelope to w (typically stderr).
|
||||
|
||||
@@ -24,7 +24,7 @@ func TestWriteEnvelope_SuccessWithData(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestWriteEnvelope_OmitDataWhenNil(t *testing.T) {
|
||||
// mutation 无 payload 时 data 字段应被省略(omitempty)
|
||||
// Mutation with no payload: the data field should be omitted (omitempty).
|
||||
var buf bytes.Buffer
|
||||
if err := output.WriteEnvelope(&buf, nil, nil, false, ""); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
)
|
||||
|
||||
// InitEvent is the CLI-injected lifecycle event written at the head of a
|
||||
// streaming command's NDJSON output (§5.3). Carries enough context for an
|
||||
// agent to thread follow-ups (session id, kb id, model, profile).
|
||||
// streaming command's NDJSON output. Carries enough context for an agent to
|
||||
// thread follow-ups (session id, kb id, model, profile).
|
||||
//
|
||||
// Type field is always "init"; the JSON tag fixes the wire shape.
|
||||
type InitEvent struct {
|
||||
@@ -37,7 +37,8 @@ func EmitInit(w io.Writer, ev InitEvent) error {
|
||||
}
|
||||
|
||||
// EmitSDKEvent passes through the raw SDK event as one NDJSON line.
|
||||
// SDK is source of truth for event vocab (§5.1).
|
||||
// The SDK is the source of truth for event vocabulary; the CLI does not
|
||||
// rename or reshape events.
|
||||
func EmitSDKEvent(w io.Writer, ev any) error {
|
||||
return WriteNDJSONLine(w, ev)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
)
|
||||
|
||||
// keyringService is the namespace prefix passed to OS keyring backends.
|
||||
// Final keyring entries look like service="weknora:<context>", account="<key>".
|
||||
// Final keyring entries look like service="weknora:<profile>", account="<key>".
|
||||
const keyringService = "weknora"
|
||||
|
||||
// KeyringStore is the OS-backed credential store: macOS Keychain, GNOME
|
||||
@@ -20,15 +20,15 @@ type KeyringStore struct{}
|
||||
// real Get/Set surfaces ErrUnsupported on systems with no keyring backend.
|
||||
func NewKeyringStore() *KeyringStore { return &KeyringStore{} }
|
||||
|
||||
// service returns the per-context service identifier. Splitting by context
|
||||
// service returns the per-profile service identifier. Splitting by profile
|
||||
// (rather than by host) follows the Supabase pattern: a user with
|
||||
// two tenants on the same host gets two distinct keyring namespaces.
|
||||
func (k *KeyringStore) service(context string) string {
|
||||
return keyringService + ":" + context
|
||||
func (k *KeyringStore) service(profile string) string {
|
||||
return keyringService + ":" + profile
|
||||
}
|
||||
|
||||
func (k *KeyringStore) Get(context, key string) (string, error) {
|
||||
v, err := keyring.Get(k.service(context), key)
|
||||
func (k *KeyringStore) Get(profile, key string) (string, error) {
|
||||
v, err := keyring.Get(k.service(profile), key)
|
||||
if errors.Is(err, keyring.ErrNotFound) {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
@@ -38,15 +38,15 @@ func (k *KeyringStore) Get(context, key string) (string, error) {
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (k *KeyringStore) Set(context, key, value string) error {
|
||||
if err := keyring.Set(k.service(context), key, value); err != nil {
|
||||
func (k *KeyringStore) Set(profile, key, value string) error {
|
||||
if err := keyring.Set(k.service(profile), key, value); err != nil {
|
||||
return fmt.Errorf("keyring set: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (k *KeyringStore) Delete(context, key string) error {
|
||||
err := keyring.Delete(k.service(context), key)
|
||||
func (k *KeyringStore) Delete(profile, key string) error {
|
||||
err := keyring.Delete(k.service(profile), key)
|
||||
if errors.Is(err, keyring.ErrNotFound) {
|
||||
return nil
|
||||
}
|
||||
@@ -57,8 +57,8 @@ func (k *KeyringStore) Delete(context, key string) error {
|
||||
}
|
||||
|
||||
// Ref returns the keychain:// URI under which a secret is stored.
|
||||
func (k *KeyringStore) Ref(context, key string) string {
|
||||
return "keychain://" + keyringService + "/" + context + "/" + key
|
||||
func (k *KeyringStore) Ref(profile, key string) string {
|
||||
return "keychain://" + keyringService + "/" + profile + "/" + key
|
||||
}
|
||||
|
||||
// NewBestEffortStore returns a Store that prefers keyring (when available)
|
||||
@@ -69,7 +69,7 @@ func (k *KeyringStore) Ref(context, key string) string {
|
||||
// it is unsupported we return the file store directly.
|
||||
func NewBestEffortStore() (Store, error) {
|
||||
k := NewKeyringStore()
|
||||
// Use a sentinel context/key that should not exist; we expect either
|
||||
// Use a sentinel profile/key that should not exist; we expect either
|
||||
// ErrNotFound (backend works, just empty) or ErrUnsupported (no backend).
|
||||
_, err := keyring.Get(k.service("__probe__"), "__probe__")
|
||||
if err == nil || errors.Is(err, keyring.ErrNotFound) {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// $XDG_CONFIG_HOME/weknora/secrets/, used as a fallback when no keyring
|
||||
// backend is available).
|
||||
//
|
||||
// Namespace convention: "weknora:<context>:<key>" where key is "access",
|
||||
// Namespace convention: "weknora:<profile>:<key>" where key is "access",
|
||||
// "refresh", or "api_key".
|
||||
package secrets
|
||||
|
||||
@@ -22,18 +22,18 @@ var ErrNotFound = errors.New("secret: not found")
|
||||
|
||||
// Store is the abstraction CLI commands depend on; tests inject in-memory impls.
|
||||
//
|
||||
// Ref returns a stable URI (e.g. file://<context>/<key> or
|
||||
// keychain://weknora/<context>/<key>) describing where a saved secret lives.
|
||||
// Ref returns a stable URI (e.g. file://<profile>/<key> or
|
||||
// keychain://weknora/<profile>/<key>) describing where a saved secret lives.
|
||||
// Backends own their scheme so commands never need to type-assert the
|
||||
// concrete implementation.
|
||||
type Store interface {
|
||||
Get(context, key string) (string, error)
|
||||
Set(context, key, value string) error
|
||||
Delete(context, key string) error
|
||||
Ref(context, key string) string
|
||||
Get(profile, key string) (string, error)
|
||||
Set(profile, key, value string) error
|
||||
Delete(profile, key string) error
|
||||
Ref(profile, key string) string
|
||||
}
|
||||
|
||||
// FileStore writes 0600 plain-text files under $XDG_CONFIG_HOME/weknora/secrets/<context>.
|
||||
// FileStore writes 0600 plain-text files under $XDG_CONFIG_HOME/weknora/secrets/<profile>.
|
||||
// It is the headless / CI default and the keychain fallback.
|
||||
type FileStore struct {
|
||||
root string
|
||||
@@ -54,12 +54,12 @@ func defaultRoot() (string, error) {
|
||||
return xdg.Path("XDG_CONFIG_HOME", ".config", "secrets")
|
||||
}
|
||||
|
||||
func (f *FileStore) path(context, key string) string {
|
||||
return filepath.Join(f.root, context, key)
|
||||
func (f *FileStore) path(profile, key string) string {
|
||||
return filepath.Join(f.root, profile, key)
|
||||
}
|
||||
|
||||
func (f *FileStore) Get(context, key string) (string, error) {
|
||||
data, err := os.ReadFile(f.path(context, key))
|
||||
func (f *FileStore) Get(profile, key string) (string, error) {
|
||||
data, err := os.ReadFile(f.path(profile, key))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
@@ -69,19 +69,19 @@ func (f *FileStore) Get(context, key string) (string, error) {
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func (f *FileStore) Set(context, key, value string) error {
|
||||
dir := filepath.Join(f.root, context)
|
||||
func (f *FileStore) Set(profile, key, value string) error {
|
||||
dir := filepath.Join(f.root, profile)
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return fmt.Errorf("mkdir secrets dir: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(f.path(context, key), []byte(value), 0o600); err != nil {
|
||||
if err := os.WriteFile(f.path(profile, key), []byte(value), 0o600); err != nil {
|
||||
return fmt.Errorf("write secret: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *FileStore) Delete(context, key string) error {
|
||||
err := os.Remove(f.path(context, key))
|
||||
func (f *FileStore) Delete(profile, key string) error {
|
||||
err := os.Remove(f.path(profile, key))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
@@ -93,8 +93,8 @@ func (f *FileStore) Delete(context, key string) error {
|
||||
// file:///C:/... on Windows) - the wire format must not depend on platform
|
||||
// path separator since this string is persisted to config.yaml and may be
|
||||
// consumed by tooling on a different OS.
|
||||
func (f *FileStore) Ref(context, key string) string {
|
||||
p := filepath.ToSlash(f.path(context, key))
|
||||
func (f *FileStore) Ref(profile, key string) string {
|
||||
p := filepath.ToSlash(f.path(profile, key))
|
||||
if !strings.HasPrefix(p, "/") {
|
||||
// Windows absolute path "C:/..." needs a leading slash to form a
|
||||
// proper file:/// URI ("file:///C:/...").
|
||||
|
||||
Reference in New Issue
Block a user