feat(tb-lf): full share + alias CRUD (M3)#37
Merged
Conversation
Add `tb-lf share alias` subcommands to create/repoint, list, and delete per-user pretty aliases (`/u/<user_id>/<slug>`) for DevPortal shares. Server-side endpoints landed in M2/M3 (#63–#66). This CLI is the last M3 plan chunk. - `set <slug> <token-or-url>` does GET-first orchestration: resolves the target share, looks up an existing alias by slug, then either POSTs (create) or PATCHes (repoint). - `list` renders all of the caller's aliases with their target share's title, token, and visibility (or `(deleted)` for dangling rows). - `rm <slug>` finds by slug and DELETEs. INV-5 (unlisted opt-in): on transitions into `unlisted` the CLI gates on a TTY `[y/N]` prompt; non-TTY callers must pass `--force`. The server does NOT enforce this — the CLI gate IS the surface. Slug regex + reserved list are ported verbatim from `devportal/app/models/share_alias.rb`. Parity is locked by `tests/share_alias_slug_validation.rs`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
QA Pass 1 self-review surfaced three small things to tighten before the M3 PR opens. - Rename `ShareAliasUpdatePayload` (with a dead `slug: None` field) to `ShareAliasRepointPayload` that only carries `share_id`. The slug lookup happens BEFORE the PATCH (we matched on the normalized slug), so by construction the stored slug already equals the input — the optional slug field could never be set, and the rename semantics for the CLI is delete + recreate (not a `set` op). - Fix `resolve_own_share_by_target`'s error message — it referenced a non-existent `--visibility` flag. Rewritten to suggest `tb-lf share alias list` (where the user can copy a token from a share they already own). - Drop the duplicate `repoint_from_private_to_unlisted_prompts` test — it calls `opt_in_gate(false, true)` with the same arguments as `create_branch_into_unlisted_target_prompts`. Both names mapped to the same `(false, true)` arm in `opt_in_gate`; collapse to one test whose body comment explains both framings. Gates re-verified: `cargo test -p tb-lf` (27 → 26 tests, all green), `cargo fmt --check`, `cargo clippy -D warnings` — all clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bundles the previously-queued share CRUD subcommands into the M3 PR. Manual smoke surfaced that having `share alias set/list/rm` without matching share-side verbs was incomplete from the CLI perspective — recurring-report workflows need to rename/repurpose/retire shares without opening the browser. Server endpoints landed in M2 (`/spa_api/shares` index/update/destroy); this is pure client work. - `tb-lf share list` — GET /spa_api/shares, render bundled URL + token + visibility per row. - `tb-lf share update <target> [--title <t>] [--visibility <v>] [--force]` — PATCH /spa_api/shares/:id. `<target>` accepts bare token OR /s/:token URL (same parser as alias). Local guards: at least one of --title / --visibility required; --visibility must be `private` or `unlisted`. Asymmetric escalation gate mirrors EditShareSheet's AlertDialog — on `private` → `unlisted` the CLI prompts `[y/N]` (TTY) or requires `--force` (non-TTY). De-escalation `unlisted` → `private` emits a one-line stderr notice mirroring the SPA's M2 toast copy. Same-side changes (private→private, unlisted→unlisted) are silent. - `tb-lf share rm <target>` — DELETE /spa_api/shares/:id. Server soft-deletes + enqueues the background purge job. New `tb_lf::share` module (sister to `tb_lf::share_alias`): - `share_url(base, token)` formatter (trailing-slash safe). - `ShareVisibilityChange` enum + `visibility_change(current, new)` — pure decision so escalation/de-escalation/no-op branches are unit-testable without HTTP. - `SHARE_ESCALATION_COPY` constant — verbatim mirror of the SPA's EditShareSheet AlertDialog text. - 2 unit tests (URL formatter + full visibility-change matrix). Total CLI test count: 33 (was 26 — 8 new share unit + 0 net change elsewhere; existing alias tests untouched). Gates: `cargo test -p tb-lf` 33/0, `cargo fmt --check` clean, `cargo clippy -D warnings` clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds the full share + alias CRUD surface to
tb-lf— manages DevPortal Shares and their per-user pretty aliases (/u/<user_id>/<slug>) entirely from the terminal. Skills and recurring-report workflows can now upload, list, rename, retire, and alias shares without opening the browser.Depends on devportal #292 — the alias endpoints (
/spa_api/share_aliases) and the/u/<user_id>/<slug>viewer route land there. The share CRUD endpoints (/spa_api/shares) landed back in M2 already, so the share-CRUD-via-CLI piece technically doesn't need #292 — but they ship together since they're the same release. Don't merge until #292 is inmasterand deployed.Bumps
tb-lfto 0.8.0 (release tag created post-merge per the standard workflow).What's new
Aliases (new in M3)
Share CRUD (filled in during QA — previously queued, bundled into this PR)
<token-or-url>works the same in bothshare alias setandshare update / rm— bare token OR/s/:tokenURL (full or bare).Asymmetric escalation gates (CLI mirrors SPA AlertDialogs)
share alias setinto anunlistedtarget (INV-5):[y/N]prompt with the chunk #49 / #62 copy.--forceto override.share update --visibility unlistedon a currently-private share:[y/N]prompt with the SPA EditShareSheet's verbatim copy ("Anyone with this URL will be able to view it without logging in. Continue?").--forceto override.GET-first orchestration for
alias setNo upsert endpoint on the server (REST stays pure). The CLI does:
GET /spa_api/shares→ resolve the token to the user's own share (server pre-scopes tocreated_by).GET /spa_api/share_aliases→ look up an existing row by slug.was_unlistedandbecomes_unlisted).POST /spa_api/share_aliases(create) orPATCH /spa_api/share_aliases/:id(repoint).Two roundtrips on create; one on repoint. Race window in single-user shell is acceptable.
What shipped
7ead34ctb-lf #67: share alias set / list / rm. Newtb_lf::share_aliaspure-logic module (slug regex/reserved port, token-or-URL parser,OptInGatedecision matrix). Fourdevportal_*JSON helpers onDevPortalClientfor the bare-URL endpoints (existingpatch/deletetarget/spa_api/ai, alias + share endpoints are off the bare URL).e8b9940bump version to 0.8.0.4990bffQA Pass 1 cleanup. RenamedShareAliasUpdatePayload→ShareAliasRepointPayloadand dropped the deadslugfield. Fixed an error message that referenced a non-existent--visibilityflag. Removed a duplicate test (repoint_from_private_to_unlisted_promptsandcreate_branch_into_unlisted_target_promptsboth calledopt_in_gate(false, true)).b6d7c8atb-lf: share list / update / rm. Pulled the previously-queued share CRUD into this release after manual smoke flagged the alias-without-share asymmetry. Newtb_lf::sharemodule (sister toshare_alias) withShareVisibilityChangematrix +share_urlformatter. Asymmetric escalation gate mirrors M2's EditShareSheet AlertDialog.Test plan
Automated coverage:
cargo test -p tb-lf— 33 tests, 0 failures (15 unit + 13 alias orchestration + 5 slug parity).cargo fmt --check -p tb-lf— clean.cargo clippy -p tb-lf -- -D warnings— clean.Manual smoke (against a local devportal running
feat/shares-aliases, or staging after #292 merges):upload→alias set <slug> <token>→alias list→alias set <slug> <different-token>(repoint) →alias rm <slug>.upload→list(row appears) →update <token> --title "Rename"(title changes) →rm <token>(row disappears).tb-lf share alias set " Weekly-Report " <token>→ "note: normalized slugWeekly-Report→weekly-report".tb-lf share alias set foo--bar <token>→ rejected without a network call. Same fornew(reserved).share updatelocal validation:tb-lf share update <token>(no flags) → rejected locally with "nothing to update".--visibility public→ rejected.tb-lf share alias set <slug> <unlisted-token>shows the[y/N]prompt with the chunk #49 copy.naborts;ycreates.tb-lf share alias set <slug> <unlisted-token> </dev/nullexits non-zero with "Not a TTY — pass --force". Adding--forcesucceeds silently.tb-lf share update <private-token> --visibility unlistedshows the[y/N]prompt with the EditShareSheet copy. Same--forcenon-TTY semantics.tb-lf share update <unlisted-token> --visibility privatesucceeds silently + emits "non-logged-in viewers will lose access" stderr notice.tb-lf share alias set my-deck https://devportal.productive.io/s/<token>(with or without trailing slash, with or without bundle subpath) works. Same forshare update https://devportal.productive.io/s/<token> --title "X".Where to look hardest if reviewing
\A(?!.*--)…); CLI does it rule-by-rule (no lookahead in Rust regex). The parity test incrates/tb-lf/tests/share_alias_slug_validation.rsmirrors the Ruby spec cases verbatim — any drift fails loudly.tb_lf::share_alias::opt_in_gate+check_unlisted_opt_ininmain.rs). Symmetry with the devportal SPA AlertDialog is the entire point — theUNLISTED_OPT_IN_COPYconstant matches chunk #49 verbatim.tb_lf::share::visibility_change+check_visibility_escalationinmain.rs). Mirrors M2's EditShareSheet AlertDialog;SHARE_ESCALATION_COPYmatches that verbatim too.std::io::IsTerminalon stdin AND stderr. The--forceflag is the only non-interactive escape; piped stdin (e.g.yes | ...) without--forceMUST exit non-zero on both INV-5 and share-escalation paths.crates/tb-lf/src/api.rs. Existingpatch/deletetarget<devportal_url>/spa_api/ai(the Langfuse API base); the newdevportal_*family targets<devportal_url>directly. There's duplication of the request-cycle pattern between the two families — see notes below.Side effects
tb-lf share upload(M1) unchanged.tb-lf share --helpgainslist,update,rm,alias.SKILL.mdgains a## Sharessection with### Manage existing shares+### Aliasessubsections.tb-lf-v0.8.0created post-merge onmainper the existing cli-toolbox release workflow.Out of scope
setonly repoints (changesshare_id); renaming an existing alias meansrm+set(or use the SPA Edit Sheet, which does both in one PATCH). The CLI'ssetlookup matches by slug — by construction the stored slug already equals the input.setand the share-update/rm verbs enumerate/spa_api/sharesand find by token client-side. Bounded by the M2 100-share cap.wiremock/mockitoinfra; decision points (token parsing, INV-5 matrix, slug validation, share visibility change matrix) live in pure functions and are exhaustively unit-tested. Adding mock HTTP infra for one feature was heavier than the value.Known follow-ups (not blocking this PR)
devportal_*API helpers duplicate the request-cycle pattern (build URL → Bearer header → send → status→error map → body→serde) shared with the existingget_raw/patch/deletemethods. Araw_requesthelper would dedupe; not done because the refactor surface compounds with the next endpoint family.🤖 Generated with Claude Code