Skip to content

feat(tb-lf): full share + alias CRUD (M3)#37

Merged
trogulja merged 4 commits into
mainfrom
feat/share-aliases
May 21, 2026
Merged

feat(tb-lf): full share + alias CRUD (M3)#37
trogulja merged 4 commits into
mainfrom
feat/share-aliases

Conversation

@trogulja
Copy link
Copy Markdown
Collaborator

@trogulja trogulja commented May 21, 2026

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 in master and deployed.

Bumps tb-lf to 0.8.0 (release tag created post-merge per the standard workflow).

What's new

Aliases (new in M3)

tb-lf share alias set weekly-report <token>
tb-lf share alias set weekly-report https://devportal.productive.io/s/<token>
tb-lf share alias list
tb-lf share alias rm weekly-report

Share CRUD (filled in during QA — previously queued, bundled into this PR)

tb-lf share list                                                  # your shares + URLs
tb-lf share update <token-or-url> --title "Q4 review"             # rename
tb-lf share update <token-or-url> --visibility unlisted            # flip visibility
tb-lf share rm <token-or-url>                                     # soft-delete (background purge)

<token-or-url> works the same in both share alias set and share update / rm — bare token OR /s/:token URL (full or bare).

Asymmetric escalation gates (CLI mirrors SPA AlertDialogs)

share alias set into an unlisted target (INV-5):

  • TTY → [y/N] prompt with the chunk #49 / #62 copy.
  • Non-TTY → exit non-zero; --force to override.
  • De-escalation (unlisted → private repoint) → single stderr notice.
  • Server does NOT enforce this — the CLI gate IS the surface.

share update --visibility unlisted on a currently-private share:

  • TTY → [y/N] prompt with the SPA EditShareSheet's verbatim copy ("Anyone with this URL will be able to view it without logging in. Continue?").
  • Non-TTY → exit non-zero; --force to override.
  • De-escalation (unlisted → private) → single stderr notice.
  • Same-side updates (private→private, unlisted→unlisted) → silent.

GET-first orchestration for alias set

No upsert endpoint on the server (REST stays pure). The CLI does:

  1. GET /spa_api/shares → resolve the token to the user's own share (server pre-scopes to created_by).
  2. GET /spa_api/share_aliases → look up an existing row by slug.
  3. Evaluate the INV-5 gate (using the GET responses to compute was_unlisted and becomes_unlisted).
  4. Branch: POST /spa_api/share_aliases (create) or PATCH /spa_api/share_aliases/:id (repoint).

Two roundtrips on create; one on repoint. Race window in single-user shell is acceptable.

What shipped

  • 7ead34c tb-lf #67: share alias set / list / rm. New tb_lf::share_alias pure-logic module (slug regex/reserved port, token-or-URL parser, OptInGate decision matrix). Four devportal_* JSON helpers on DevPortalClient for the bare-URL endpoints (existing patch/delete target /spa_api/ai, alias + share endpoints are off the bare URL).
  • e8b9940 bump version to 0.8.0.
  • 4990bff QA Pass 1 cleanup. Renamed ShareAliasUpdatePayloadShareAliasRepointPayload and dropped the dead slug field. Fixed an error message that referenced a non-existent --visibility flag. Removed a duplicate test (repoint_from_private_to_unlisted_prompts and create_branch_into_unlisted_target_prompts both called opt_in_gate(false, true)).
  • b6d7c8a tb-lf: share list / update / rm. Pulled the previously-queued share CRUD into this release after manual smoke flagged the alias-without-share asymmetry. New tb_lf::share module (sister to share_alias) with ShareVisibilityChange matrix + share_url formatter. Asymmetric escalation gate mirrors M2's EditShareSheet AlertDialog.

Test plan

Automated coverage:

  • cargo test -p tb-lf33 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):

  • Round-trip on a private share: uploadalias set <slug> <token>alias listalias set <slug> <different-token> (repoint) → alias rm <slug>.
  • Round-trip on share itself: uploadlist (row appears) → update <token> --title "Rename" (title changes) → rm <token> (row disappears).
  • Slug normalization stderr notice: tb-lf share alias set " Weekly-Report " <token> → "note: normalized slug Weekly-Report weekly-report".
  • Local validation before HTTP: tb-lf share alias set foo--bar <token> → rejected without a network call. Same for new (reserved).
  • share update local validation: tb-lf share update <token> (no flags) → rejected locally with "nothing to update". --visibility public → rejected.
  • INV-5 TTY (alias): tb-lf share alias set <slug> <unlisted-token> shows the [y/N] prompt with the chunk #49 copy. n aborts; y creates.
  • INV-5 non-TTY (alias): tb-lf share alias set <slug> <unlisted-token> </dev/null exits non-zero with "Not a TTY — pass --force". Adding --force succeeds silently.
  • Visibility escalation (share): tb-lf share update <private-token> --visibility unlisted shows the [y/N] prompt with the EditShareSheet copy. Same --force non-TTY semantics.
  • De-escalation toast (share): tb-lf share update <unlisted-token> --visibility private succeeds silently + emits "non-logged-in viewers will lose access" stderr notice.
  • URL form: 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 for share update https://devportal.productive.io/s/<token> --title "X".

Where to look hardest if reviewing

  • Slug regex parity between server and CLI. Server uses Ruby's lookahead (\A(?!.*--)…); CLI does it rule-by-rule (no lookahead in Rust regex). The parity test in crates/tb-lf/tests/share_alias_slug_validation.rs mirrors the Ruby spec cases verbatim — any drift fails loudly.
  • INV-5 gate logic (tb_lf::share_alias::opt_in_gate + check_unlisted_opt_in in main.rs). Symmetry with the devportal SPA AlertDialog is the entire point — the UNLISTED_OPT_IN_COPY constant matches chunk #49 verbatim.
  • Share visibility escalation gate (tb_lf::share::visibility_change + check_visibility_escalation in main.rs). Mirrors M2's EditShareSheet AlertDialog; SHARE_ESCALATION_COPY matches that verbatim too.
  • TTY detection uses std::io::IsTerminal on stdin AND stderr. The --force flag is the only non-interactive escape; piped stdin (e.g. yes | ...) without --force MUST exit non-zero on both INV-5 and share-escalation paths.
  • Bare-URL request family in crates/tb-lf/src/api.rs. Existing patch/delete target <devportal_url>/spa_api/ai (the Langfuse API base); the new devportal_* 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 --help gains list, update, rm, alias.
  • SKILL.md gains a ## Shares section with ### Manage existing shares + ### Aliases subsections.
  • Release: tag tb-lf-v0.8.0 created post-merge on main per the existing cli-toolbox release workflow.

Out of scope

  • CLI rename for aliasesset only repoints (changes share_id); renaming an existing alias means rm + set (or use the SPA Edit Sheet, which does both in one PATCH). The CLI's set lookup matches by slug — by construction the stored slug already equals the input.
  • Token-lookup endpoint on devportal — not added; set and the share-update/rm verbs enumerate /spa_api/shares and find by token client-side. Bounded by the M2 100-share cap.
  • HTTP mock-based orchestration tests — crate has no wiremock/mockito infra; 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)

  • The four devportal_* API helpers duplicate the request-cycle pattern (build URL → Bearer header → send → status→error map → body→serde) shared with the existing get_raw / patch / delete methods. A raw_request helper would dedupe; not done because the refactor surface compounds with the next endpoint family.

🤖 Generated with Claude Code

trogulja and others added 4 commits May 21, 2026 18:27
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>
@trogulja trogulja changed the title feat(tb-lf): share alias set / list / rm — per-user pretty aliases feat(tb-lf): full share + alias CRUD (M3) May 21, 2026
@trogulja trogulja marked this pull request as ready for review May 21, 2026 17:31
@trogulja trogulja merged commit 33218c9 into main May 21, 2026
1 check passed
@trogulja trogulja deleted the feat/share-aliases branch May 21, 2026 17:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant