mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 00:03:47 +00:00
feat(skills): add unlisted share management
This commit is contained in:
@@ -2,16 +2,19 @@
|
||||
|
||||
Status: implementation and validation in progress.
|
||||
|
||||
Last updated: 2026-08-11.
|
||||
Last updated: 2026-08-12.
|
||||
|
||||
Implementation baselines captured by this checklist update:
|
||||
|
||||
- Orca implementation: `6ba6107dba` on `skills-share` (pushed; no PR).
|
||||
- Orca Cloud: `be00db10` on `main` (opaque audience IDs in `stablyai/orca-cloud#317`, with the
|
||||
skill OIDC smoke and release-metadata convergence from `#313` and `#314`).
|
||||
- Orca implementation: `skills-share` (bearer-link changes follow baseline `6cff2aa507`; no PR).
|
||||
- Orca Cloud: isolated `feat/skill-bundles` branch (bearer-link changes follow baseline `d7d1026`).
|
||||
Production remains untouched.
|
||||
|
||||
Validated so far:
|
||||
|
||||
- Final bearer-link changes pass 143 Orca Cloud API tests with one opt-in skip; 58 focused Orca
|
||||
tests; four mixed-version wire tests; desktop/node/web typecheck; localization catalog,
|
||||
extraction, and coverage; changed-code quality; max-lines; and diff checks.
|
||||
- Local Node and web typechecks, changed-code quality gates, 94 skill-domain files with 770 tests
|
||||
passed and 3 skipped, 134 Orca Cloud API tests with one opt-in integration skip, the full Cloud
|
||||
monorepo test/typecheck/lint/build gates, and isolated Terraform formatting and validation.
|
||||
@@ -68,12 +71,14 @@ Validated so far:
|
||||
- Auth deploy run `31535179937` promoted revision `orca-cloud-auth-staging-00017-dug` at 100%
|
||||
traffic. Health, JWKS, exact GitHub OIDC claim constraints, and zero deployment-window errors
|
||||
passed.
|
||||
- API deploy run `31535438327` promoted revision `orca-cloud-api-staging-00042-hef` at 100%
|
||||
- The historical ACL-era API deploy run `31535438327` promoted revision
|
||||
`orca-cloud-api-staging-00042-hef` at 100%
|
||||
traffic with `authenticatedSmoke: true` and `skillSmoke: true`. The smoke covered artifact
|
||||
lifecycle; skill upload/finalize/download; two immutable versions; recipient and outsider
|
||||
authorization; local and remote grants; rollback selection; expiry; revocation; package
|
||||
deletion; and signed-object cleanup.
|
||||
- Cloud PR `#317` passed both required checks and merged as `be00db10`. Deploy run `31546194596`
|
||||
- Cloud PR `#317` passed both required checks and merged as `be00db10`. Its historical ACL-era
|
||||
deploy run `31546194596`
|
||||
promoted revision `orca-cloud-api-staging-00048-xom` at 100% traffic with
|
||||
`authenticatedSmoke: true` and `skillSmoke: true`; canonical health and the immutable image
|
||||
digest independently matched after promotion.
|
||||
@@ -94,10 +99,11 @@ Validated so far:
|
||||
Independent GCP reads verified Cloud SQL policy `NEVER`, every MIG target at zero, and both
|
||||
managed Cloud Run minimums at zero.
|
||||
|
||||
Rollout gate: staging infrastructure, OIDC identity exchange, route registration, and the
|
||||
authenticated artifact/skill lifecycle smoke passed. The shared staging data plane is back in its
|
||||
low-cost asleep state. Production remains untouched; physical staging install journeys, load,
|
||||
quarantine lifecycle, and soft-delete recovery gates remain.
|
||||
Rollout gate: staging infrastructure and OIDC owner identity exchange passed. The earlier
|
||||
authenticated skill lifecycle smoke predates bearer-link semantics and is not acceptance evidence
|
||||
for anonymous recipient access. The shared staging data plane is back in its low-cost asleep state.
|
||||
Production remains untouched; the anonymous bearer lifecycle, physical staging install journeys,
|
||||
load, quarantine lifecycle, and soft-delete recovery gates remain.
|
||||
|
||||
Source plan: [Agent skill sharing and installation plan](./agent-skill-sharing-installation-plan.md).
|
||||
|
||||
@@ -109,8 +115,9 @@ does not mean the surrounding phase is complete.
|
||||
|
||||
- [ ] A user can select one or many private local skills, publish one immutable Skill Bundle, and
|
||||
receive one durable Orca share URL.
|
||||
- [ ] An authorized recipient can inspect a bundle, choose all or a subset of its skills, and
|
||||
install them globally or into a Git worktree or plain folder workspace.
|
||||
- [ ] Anyone with an active unlisted link can inspect a bundle without signing in, choose all or a
|
||||
subset of its skills, and install them globally or into a Git worktree or plain folder
|
||||
workspace.
|
||||
- [ ] Installation works on local macOS, Linux, native Windows, WSL, paired Orca runtimes, and
|
||||
supported SSH targets.
|
||||
- [ ] The portable archive root conforms to Agent Plugins 1.0.0 for skills-only packages and keeps
|
||||
@@ -126,8 +133,9 @@ does not mean the surrounding phase is complete.
|
||||
- [x] Mixed-version clients and remote hosts fail safely through capability negotiation.
|
||||
- [ ] Cloud resources are Terraform-owned, monitored, recoverable, and protected by independent
|
||||
upload, download, and remote-install kill switches.
|
||||
- [ ] The existing Skills page supports multi-select sharing, selective installation, per-skill
|
||||
conflicts/results, and installed/shared bundle management.
|
||||
- [x] The existing Skills page supports multi-select sharing, selective installation, per-skill
|
||||
conflicts/results, and installed-bundle management; Settings → Share Skills provides the
|
||||
authenticated owner inventory for copying and revoking active links.
|
||||
|
||||
## 0. Confirm scope and ownership
|
||||
|
||||
@@ -158,20 +166,28 @@ does not mean the surrounding phase is complete.
|
||||
|
||||
### Product semantics
|
||||
|
||||
- [x] Confirm private means authorized Orca users and organizations, not end-to-end encryption
|
||||
from Orca Cloud operators.
|
||||
- [x] Confirm private means unlisted and protected by an unpredictable revocable bearer link, not
|
||||
end-to-end encryption from Orca Cloud operators.
|
||||
- [x] Confirm a published immutable version persists until package or version deletion; it has no
|
||||
age-based object expiry.
|
||||
- [x] Confirm an unfinished upload grant expires after 15 minutes and its quarantine object is
|
||||
deleted after one day.
|
||||
- [x] Confirm a durable share URL identifies an authorization record, not a GCS object or bearer
|
||||
credential.
|
||||
- [x] Confirm a durable share URL contains a bearer credential for a revocable authorization
|
||||
record, not a GCS object or permanent blob grant.
|
||||
- [x] Confirm a signed download grant lasts five minutes and is the maximum share-revocation lag.
|
||||
- [x] Confirm updates create immutable versions and never mutate existing package objects.
|
||||
- [x] Confirm rollback installs a selected prior immutable version.
|
||||
- [x] Confirm revoking or deleting a Cloud share does not silently remove existing local installs.
|
||||
- [x] Confirm sharing and updating use an artifact-like flow—preview, upload, durable link, update,
|
||||
unshare/delete—while retaining skill-specific version, ACL, trust, and install semantics.
|
||||
- [x] Confirm sharing and updating use an artifact-like flow—preview, upload, unlisted durable
|
||||
link, update, revoke/delete—while retaining skill-specific version, trust, and install
|
||||
semantics.
|
||||
- [x] Confirm recipients do not sign in; publishing and managing owned links still require sign-in.
|
||||
- [x] Confirm revocation blocks future resolution/download grants but does not remove installed
|
||||
copies.
|
||||
- [x] Add a Settings section named **Share Skills**, parallel to **Artifacts**, without adding any
|
||||
index or discoverability surface.
|
||||
- [x] Treat the authenticated owner-only active-link inventory as management, not recipient
|
||||
discoverability; never expose it anonymously or through browse/search APIs.
|
||||
- [x] Confirm V1 has no checked-in workspace desired-state lockfile and no automatic fleet-wide
|
||||
installation.
|
||||
- [x] Confirm one skill is a one-item bundle and a bundle may contain large selections such as 30
|
||||
@@ -618,12 +634,13 @@ does not mean the surrounding phase is complete.
|
||||
- [x] Add bounded `skill_package_audit_events` without contents, filenames, or signed values.
|
||||
- [x] Add unique package slug per organization, immutable version and digest constraints, and
|
||||
tenant-scoped archive and final GCS key/generation constraints.
|
||||
- [x] Add active-share, ACL-principal, and pending-upload expiration indexes.
|
||||
- [x] Add active-share, ACL-principal, and pending-upload expiration indexes. The ACL index remains
|
||||
legacy storage and is not used for bearer resolution.
|
||||
- [x] Add foreign keys preventing blob-reference deletion while a published version uses it.
|
||||
- [x] Accept and stream-validate both the pre-release single-skill envelope and the new Agent
|
||||
Plugins skills-only bundle envelope during the desktop migration.
|
||||
- [x] Store the detailed bundle manifest in the existing immutable version record without changing
|
||||
object, share, or ACL semantics; expose it only after exact-link authorization or to owners.
|
||||
- [x] Store the detailed bundle manifest in the existing immutable version record; expose it only
|
||||
after exact-link bearer authorization or to owners.
|
||||
- [x] Keep contained skills out of separate database rows and search indexes. Selective inspection
|
||||
reads the validated manifest attached to the exact immutable version.
|
||||
- [ ] Keep detailed file identity in the in-archive manifest rather than duplicating it into GCS
|
||||
@@ -633,12 +650,14 @@ does not mean the surrounding phase is complete.
|
||||
|
||||
### Authorization and lifecycle model
|
||||
|
||||
- [x] Begin every share resolution, grant, update lookup, and revoke mutation with an authenticated
|
||||
principal.
|
||||
- [x] Evaluate package ownership and ACL in the same request.
|
||||
- [x] Ensure share ID locates a record but never authorizes access by itself.
|
||||
- [x] Return constant-shape authorization failures that do not disclose package existence.
|
||||
- [x] Recheck organization membership for every grant, including after preview.
|
||||
- [x] Allow share resolution and share-scoped download grants without an authenticated principal.
|
||||
- [x] Treat an unpredictable active, unexpired share ID as the bearer authorization; do not apply
|
||||
legacy package ACLs to recipients.
|
||||
- [x] Return the same non-disclosing `404` for missing, expired, revoked, or deleted shares.
|
||||
- [x] Keep authenticated owner checks on upload, finalize, package/version management, share
|
||||
creation, revocation, and deletion.
|
||||
- [x] Apply distributed per-IP abuse limits to anonymous resolution and grant requests without
|
||||
persisting raw requester addresses.
|
||||
- [ ] Apply organization retention and legal deletion rules over product rollback retention.
|
||||
- [x] Make package deletion revoke shares before dereferencing immutable objects.
|
||||
- [x] Delete an object only after a transaction proves no retained version references it.
|
||||
@@ -664,20 +683,22 @@ does not mean the surrounding phase is complete.
|
||||
- [x] Delete quarantine bytes after success and rely on lifecycle cleanup for abandonment.
|
||||
- [x] Make all mutating endpoints accept idempotency keys.
|
||||
|
||||
### Package, version, ACL, and share APIs
|
||||
### Package, version, and share APIs
|
||||
|
||||
- [x] Implement package creation and version publication under a stable package identity.
|
||||
- [x] Implement package details and paginated version history.
|
||||
- [x] Implement organization and selected-user access management.
|
||||
- [x] Stop accepting organization/selected-user audience fields for new bearer shares; retain
|
||||
legacy ACL storage only as migration-compatible data.
|
||||
- [x] Implement durable share creation with optional pinned version and expiry.
|
||||
- [x] Implement share resolution with authenticated metadata preview.
|
||||
- [x] Implement anonymous bearer-link metadata preview.
|
||||
- [x] Implement immediate share revocation for new grant requests.
|
||||
- [x] Implement Cloud package/version deletion with retention and reference checks.
|
||||
- [x] Implement version update lookup and rollback selection.
|
||||
|
||||
### Download grants
|
||||
|
||||
- [x] Implement `POST /v1/skill-shares/<share-id>/download-grants` after fresh ACL evaluation.
|
||||
- [x] Implement anonymous `POST /v1/skill-shares/<share-id>/download-grants` after fresh active-link
|
||||
evaluation.
|
||||
- [x] Generate a five-minute V4 signed GET URL for the exact immutable key and stored generation.
|
||||
- [x] Set only response content type and safe attachment filename overrides.
|
||||
- [x] Grant no list or write capability.
|
||||
@@ -687,8 +708,11 @@ does not mean the surrounding phase is complete.
|
||||
|
||||
### Cloud tests
|
||||
|
||||
- [x] Test organization, user, pinned-link, expiry, and revocation ACL behavior.
|
||||
- [x] Test membership removal between preview, grant, and download.
|
||||
- [x] Test anonymous latest/pinned preview and download grants without an Authorization header.
|
||||
- [x] Test missing, expired, revoked, and deleted shares all fail without disclosure.
|
||||
- [x] Test authenticated owner management remains protected.
|
||||
- [x] Test anonymous per-IP rate limiting without recording raw IP addresses.
|
||||
- [x] Test the authenticated owner-only active-link inventory and ensure responses are not cached.
|
||||
- [x] Test expired, reused, wrong-tenant, wrong-key, wrong-size, and wrong-hash uploads.
|
||||
- [x] Test malformed, oversized, and resource-exhausting archives during finalization.
|
||||
- [x] Test finalization semaphore saturation and retry behavior.
|
||||
@@ -704,8 +728,8 @@ does not mean the surrounding phase is complete.
|
||||
|
||||
### Cloud client
|
||||
|
||||
- [x] Add typed calls for upload grants, GCS upload, finalization, package/version catalog,
|
||||
ACL/access management, share creation/resolution/revocation, and download grants.
|
||||
- [x] Make share resolution and share-scoped download-grant calls anonymous while keeping upload,
|
||||
package/version management, share creation, revocation, and deletion authenticated.
|
||||
- [x] Keep desktop contracts storage-provider-neutral.
|
||||
- [x] Implement bounded progress, cancellation, retry, and idempotency behavior.
|
||||
- [x] Redact signed policies, URLs, credentials, private paths, and package contents at creation.
|
||||
@@ -714,23 +738,26 @@ does not mean the surrounding phase is complete.
|
||||
|
||||
- [x] Read and apply `docs/STYLEGUIDE.md`; use canonical CSS tokens and shadcn primitives.
|
||||
- [x] Add **Share skill** to eligible installed and workspace skills.
|
||||
- [x] Show name, description, author, organization, file count, total size, scripts, and executable
|
||||
files before upload.
|
||||
- [x] Let the author choose organization or selected-person access.
|
||||
- [x] Show name, description, author, file count, total size, scripts, executable files, and the
|
||||
unlisted-link warning before upload.
|
||||
- [x] Remove organization and selected-person audience controls from bearer-link publishing.
|
||||
- [x] Accept optional release label and notes.
|
||||
- [x] Package the exact previewed bytes and invalidate preview after source drift.
|
||||
- [x] Show bounded upload and finalization progress with cancellation.
|
||||
- [x] Return a copyable durable Orca URL after publication.
|
||||
- [x] Add access editing, version publishing, unshare, and package deletion actions.
|
||||
- [x] Remove access editing from the bearer-share workflow; keep version publishing, revoke, and
|
||||
package deletion actions.
|
||||
- [x] Make clear that unsharing blocks future installs but does not remove installed copies.
|
||||
- [ ] Add **Share skills**, **Install from link**, and unified **Manage** to the existing Skills page
|
||||
header. The first two are present; management still covers installed packages only.
|
||||
- [x] Add **Share skills**, **Install from link**, and installed-package **Manage** to the existing
|
||||
Skills page header; expose active link copy/revocation in Settings → **Share Skills** so links
|
||||
remain manageable even when the source was never a managed install.
|
||||
- [x] Add selection mode whose selection survives search/filter changes and an explicit **Select
|
||||
all results** action; never select all implicitly.
|
||||
- [x] Keep the per-card share action as a one-skill bundle shortcut.
|
||||
- [ ] Disable ineligible skills with visible reasons and block duplicate skill names before review.
|
||||
- [x] Review bundle name, skill/file/byte counts, script/executable warnings, expandable per-skill
|
||||
details, audience, and release notes.
|
||||
details, unlisted-link behavior, and release notes.
|
||||
- [x] Add Settings → **Share Skills** with artifact-like explanation and a route to the Skills page.
|
||||
- [ ] Show named preparation, upload, verification, and link-publication progress and return one
|
||||
durable link for the bundle.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Status: implementation and validation in progress.
|
||||
|
||||
Last updated: 2026-08-11.
|
||||
Last updated: 2026-08-12.
|
||||
|
||||
Implementation checklist:
|
||||
[Agent skill sharing implementation checklist](./agent-skill-sharing-implementation-checklist.md).
|
||||
@@ -27,8 +27,9 @@ Orca will:
|
||||
|
||||
1. Package one or many local skills into one immutable, content-addressed artifact and durable
|
||||
link.
|
||||
2. Store private artifacts behind Orca Cloud authorization and durable share records.
|
||||
3. Resolve a share into a short-lived download grant after checking access.
|
||||
2. Store private artifacts behind unlisted, revocable Orca Cloud share records.
|
||||
3. Treat possession of an active share link as recipient authorization, then resolve it into a
|
||||
short-lived download grant.
|
||||
4. Execute installation on the machine that will use the skill.
|
||||
5. Validate, stage, commit, and verify the package without silently overwriting local work.
|
||||
6. Let the recipient install all or a selected subset, with conflicts and results reported per
|
||||
@@ -38,6 +39,8 @@ Orca will:
|
||||
8. Record bundle/version provenance independently from the community CLI's lockfiles.
|
||||
9. Keep every share unlisted: bundles are reachable only through their durable link and never
|
||||
appear in search, browse, organization-library, or marketplace surfaces.
|
||||
10. Give authenticated owners a private active-link inventory for copy and revocation; this is a
|
||||
management surface, not recipient discovery.
|
||||
|
||||
The portable archive root follows Agent Plugins 1.0.0 for a skills-only package. Orca does not
|
||||
present Skill Bundles as a new executable plugin system: MCP servers, hooks, processes, connectors,
|
||||
@@ -50,12 +53,14 @@ that larger package-manager surface.
|
||||
|
||||
## Current execution status
|
||||
|
||||
The last pushed Orca baseline is `41a7b45c4e`; current Skill Bundle work remains uncommitted on
|
||||
`skills-share` and no Orca pull request exists. Orca Cloud implementation is merged on `main`
|
||||
through `be00db10`. The authenticated OIDC smoke landed in `stablyai/orca-cloud#313`; the narrow
|
||||
Auth release-metadata convergence follow-up landed in `#314`; the finalization and lifecycle fixes
|
||||
landed in `#317`. Staging infrastructure and skill routes are deployed and validated; production
|
||||
remains untouched. Local, Windows, WSL, paired-runtime, and
|
||||
The Orca implementation is on `skills-share`; bearer-link changes follow baseline `6cff2aa507` and
|
||||
no Orca pull request exists. Cloud bundle ingestion and bearer-link work are isolated on
|
||||
`feat/skill-bundles` after baseline `d7d1026`. The authenticated OIDC smoke landed in
|
||||
`stablyai/orca-cloud#313`; the narrow Auth release-metadata convergence
|
||||
follow-up landed in `#314`; the finalization and lifecycle fixes landed in `#317`. Staging
|
||||
infrastructure exists, but its earlier authenticated recipient smoke predates the anonymous
|
||||
bearer-link model and must be replaced before release. Production remains untouched. Local,
|
||||
Windows, WSL, paired-runtime, and
|
||||
Docker-backed SSH validation are substantially complete; the implementation checklist records the
|
||||
exact evidence and remaining physical-host and failure-recovery gates.
|
||||
|
||||
@@ -71,10 +76,11 @@ rejected before transfer. The original single-skill path remains active for lega
|
||||
bundle management migrates.
|
||||
|
||||
Cloud bundle validation is pushed on isolated branch `feat/skill-bundles` at `d7d1026`. It accepts
|
||||
both envelopes during migration, stores the detailed bundle manifest with the immutable version,
|
||||
and leaves object storage, ACLs, shares, and download grants unchanged. The full API suite passes
|
||||
136 tests with one skipped. It is not merged or deployed until the desktop publisher emits the new
|
||||
format and the staging smoke is updated.
|
||||
both envelopes during migration and stores the detailed bundle manifest with the immutable
|
||||
version. Local bearer-link changes make active share IDs the recipient credential, keep owner
|
||||
operations authenticated, retain legacy ACL rows only as migration storage, and prevent concrete
|
||||
share URLs from entering request logs. It is not merged or deployed until the final gates and
|
||||
anonymous staging smoke pass.
|
||||
|
||||
The latest resilience pass adds bounded convergence after a lost final install response for both
|
||||
paired runtimes and SSH. A staged retry creates a new upload instead of reusing a consumed upload.
|
||||
@@ -167,7 +173,7 @@ attribution. Revisit this decision if implementation work ever proposes copying
|
||||
|
||||
- Reimplementing all `skills` CLI source parsing and provider integrations.
|
||||
- Supporting all upstream agent paths on day one.
|
||||
- Publishing private package bytes through a public bearer URL.
|
||||
- Publishing package bytes through a permanent or directly addressable blob URL.
|
||||
- Automatically installing a shared skill on every organization machine.
|
||||
- Merging local modifications with a newer shared package version.
|
||||
- Treating installation as a security sandbox. A skill contains instructions and scripts and
|
||||
@@ -196,8 +202,8 @@ The review dialog shows:
|
||||
- Bundle name and selected skill list.
|
||||
- Skill, file, and byte counts.
|
||||
- Included scripts and executable files, with an expandable review for each skill.
|
||||
- Current author identity and organization.
|
||||
- Access choice: organization or selected people.
|
||||
- Current author identity.
|
||||
- A clear warning that anyone with the unlisted link can inspect and install the bundle.
|
||||
- Optional version label and release notes.
|
||||
|
||||
After confirmation, Orca validates and packages the exact bytes shown in the preview. Progress is
|
||||
@@ -208,10 +214,11 @@ one durable URL such as:
|
||||
https://app.orca.dev/skills/share/<share-id>
|
||||
```
|
||||
|
||||
The URL identifies a revocable share record, not a blob-storage object or bearer grant. Every
|
||||
recipient still authenticates and passes the share ACL. Revocation, organization membership
|
||||
changes, version selection, and audit history therefore remain effective after the link has been
|
||||
copied.
|
||||
The URL identifies a revocable bearer share record, not a blob-storage object or permanent GCS
|
||||
grant. Recipients do not sign in: possession of an active, unexpired link authorizes inspection and
|
||||
a short-lived download grant. Revocation blocks future resolution and grants, while already
|
||||
installed copies remain. Publishing, listing owned shares, version management, revocation, and
|
||||
deletion still require the owner to sign in.
|
||||
|
||||
### Installing
|
||||
|
||||
@@ -232,7 +239,12 @@ An incompatible selected runtime receives an update-required result before trans
|
||||
**Manage** separates:
|
||||
|
||||
- Installed bundles: update, rollback, install on another machine, inspect skills, and remove.
|
||||
- Shared bundles: copy link, change access, revoke, publish version, and delete the Cloud package.
|
||||
- Shared bundles: copy link, revoke, publish version, and delete the Cloud package.
|
||||
|
||||
Settings includes a **Share Skills** section, parallel to **Artifacts**, explaining unlisted-link
|
||||
behavior, retention, revocation, and the fact that installed copies are unaffected. It links to the
|
||||
Skills page for publishing and management; it does not introduce a searchable catalog or a second
|
||||
sharing workflow.
|
||||
|
||||
Bundle update or removal never silently destroys locally modified skills.
|
||||
|
||||
@@ -247,7 +259,7 @@ Selected skill folders
|
||||
Package builder -- validates and creates immutable artifact
|
||||
|
|
||||
v
|
||||
Orca Cloud -- private blob + version metadata + ACL + durable share
|
||||
Orca Cloud -- private blob + version metadata + revocable bearer share
|
||||
|
|
||||
v
|
||||
Download grant -- short-lived and scoped to one immutable digest
|
||||
@@ -336,7 +348,8 @@ Rules:
|
||||
than a top-level custom field or GCS metadata alone. It therefore survives export, SSH/remote
|
||||
transfer, and offline validation and can describe every skill and file.
|
||||
- GCS custom metadata stays compact and operational. PostgreSQL stores only the package, share,
|
||||
version, ACL, and lifecycle records needed to resolve a known link or manage an owned bundle.
|
||||
version, ownership, and lifecycle records needed to resolve a known link or manage an owned
|
||||
bundle. Legacy ACL records may remain during migration but never gate bearer-link access.
|
||||
- Orca constructs a fresh staging root. If imported content already contains
|
||||
`dev.orca.skill-sharing`, Orca accepts only an appropriate recognized schema and rejects unknown,
|
||||
malformed, or conflicting contents; it never overwrites the namespace silently.
|
||||
@@ -481,7 +494,7 @@ returns `429` or `503` with `Retry-After`; it does not queue unbounded package b
|
||||
|
||||
### Download grants
|
||||
|
||||
After resolving a share and rechecking its ACL, `orca-cloud-api` creates a V4 signed GET URL with:
|
||||
After resolving an active bearer share, `orca-cloud-api` creates a V4 signed GET URL with:
|
||||
|
||||
- Exact final object key and stored generation.
|
||||
- Five-minute expiration.
|
||||
@@ -527,7 +540,8 @@ Create migrations for:
|
||||
bundle manifest needed to inspect a known share without downloading its private archive.
|
||||
- `skill_package_uploads`: tenant/user binding, quarantine key and generation, expected identities,
|
||||
expiration, finalization state, and failure category.
|
||||
- `skill_package_acl`: organization or user principal, permission, creator, and timestamps.
|
||||
- `skill_package_acl`: legacy organization or user access records retained for migration until a
|
||||
later schema cleanup; these do not authorize or deny bearer-share resolution.
|
||||
- `skill_share_records`: unpredictable share ID, package ID, optional pinned version, expiration,
|
||||
revocation state, creator, and timestamps.
|
||||
- `skill_package_audit_events`: bounded security/lifecycle event metadata without package contents
|
||||
@@ -540,13 +554,15 @@ Required constraints and indexes include:
|
||||
- Tenant-scoped archive identity and final GCS object key/generation pair.
|
||||
- Share lookup by unpredictable ID and active/revoked state.
|
||||
- No package, version, or contained-skill search/list index; share links are intentionally unlisted.
|
||||
- ACL lookup by package and principal.
|
||||
- Owner lookup for authenticated management operations.
|
||||
- Pending-upload lookup by owner and expiration.
|
||||
- Foreign keys that prevent removing a blob reference while a published version uses it.
|
||||
|
||||
Every share resolution, grant, update lookup, and revoke mutation begins with an authenticated
|
||||
principal and evaluates package ownership plus ACL in the same request. The share ID locates the
|
||||
record but does not itself authorize access.
|
||||
Share resolution and share-scoped download grants accept no user credential: an unpredictable,
|
||||
active, unexpired share ID is the bearer credential. Anonymous requests receive the same
|
||||
non-disclosing `404` for missing, expired, revoked, or deleted shares and remain subject to
|
||||
distributed per-IP abuse limits. Package/version management, share creation, owner listing,
|
||||
revocation, and deletion require an authenticated owner.
|
||||
|
||||
### Cloud Run changes
|
||||
|
||||
@@ -650,7 +666,7 @@ policies, signed URLs, or ACL membership.
|
||||
7. Verify the migration-ready event, zero error logs, ordinary artifact behavior, and `404` skill
|
||||
route boundary before changing any control.
|
||||
8. Enable route registration for the staging identity and run upload/finalize/download tests,
|
||||
including expired policies, ACL denial, oversized uploads, corrupt archives, deduplication,
|
||||
including expired policies, anonymous bearer resolution, oversized uploads, corrupt archives, deduplication,
|
||||
revocation, and object cleanup.
|
||||
9. Run a staging desktop-to-runtime installation and verify logs contain no grants or private
|
||||
contents.
|
||||
@@ -796,11 +812,13 @@ and test the same contract accurately.
|
||||
### Cloud client
|
||||
|
||||
- Skill upload grant creation and finalization.
|
||||
- Share creation, resolution, revocation, and access management.
|
||||
- Authenticated share creation and revocation; anonymous share resolution.
|
||||
- Download grant creation.
|
||||
- Owned-package management and exact package/version lookup by ID; no browse or search API.
|
||||
- Manager-only package details include current user/organization access and active, unexpired
|
||||
share records; non-managers never receive that management metadata.
|
||||
- Authenticated owner-only active-link inventory for cross-device copy and revocation; no public or
|
||||
recipient inventory.
|
||||
- Manager-only package details include ownership and active, unexpired share records; anonymous
|
||||
recipients never receive management metadata.
|
||||
|
||||
Exact files follow the repository that owns Orca Cloud APIs; desktop contracts stay provider
|
||||
neutral.
|
||||
@@ -815,10 +833,12 @@ neutral.
|
||||
|
||||
### Renderer
|
||||
|
||||
- Multi-select bundle share review and access dialog.
|
||||
- Multi-select bundle share review and unlisted-link warning.
|
||||
- Share completion dialog with copyable durable link.
|
||||
- Access editing, active-link revocation, immutable-version deletion, and Cloud-package deletion
|
||||
with explicit confirmation and copy explaining that installed copies remain local.
|
||||
- Active-link copy/revocation, immutable-version deletion, and Cloud-package deletion with explicit
|
||||
confirmation and copy explaining that installed copies remain local.
|
||||
- Settings → Share Skills owner inventory so newly published links remain manageable even when the
|
||||
source skill has no managed-install receipt.
|
||||
- Selective install preview with destination, scope, coverage, and per-skill conflict state.
|
||||
- Aggregate install progress and grouped per-skill outcomes.
|
||||
- Installed/shared bundle management, update, rollback, removal, and incomplete-coverage actions on
|
||||
@@ -1174,8 +1194,8 @@ destination containment, and expected identities must all agree.
|
||||
|
||||
### Cloud tests
|
||||
|
||||
- Organization/user/link ACLs and revocation.
|
||||
- Expired grants and membership removed between preview and download.
|
||||
- Anonymous active-link resolution, expiry, revocation, and non-disclosing missing-link failures.
|
||||
- Owner authentication for package and share management.
|
||||
- Upload finalization digest mismatch.
|
||||
- Blob deduplication without cross-tenant information leakage.
|
||||
- Quota and rate-limit behavior.
|
||||
@@ -1261,19 +1281,19 @@ Deliver:
|
||||
- Reviewed Terraform for the dedicated private GCS bucket, lifecycle, CORS, bucket-scoped IAM,
|
||||
service-account signing, `orca_skills` database, database secret, Cloud SQL attachment,
|
||||
monitoring, and budgets.
|
||||
- PostgreSQL migrations for package, version, upload, ACL, share, and audit records.
|
||||
- PostgreSQL migrations for package, version, upload, share, ownership, and audit records, with
|
||||
legacy ACL storage tolerated during migration.
|
||||
- Upload/finalize APIs using bounded V4 signed POST policies and private content-addressed GCS
|
||||
objects.
|
||||
- Version, ACL, share, revocation, five-minute download grant, quota, and audit behavior.
|
||||
- Share preview and access UI.
|
||||
- Version, bearer-share, revocation, five-minute download grant, quota, and audit behavior.
|
||||
- Share preview and owner-management UI.
|
||||
- Install preview and local-machine installation UI.
|
||||
- Durable links and short-lived grants.
|
||||
- Update availability and version rollback.
|
||||
|
||||
Exit criteria:
|
||||
|
||||
- A copied durable link remains inaccessible unless the authenticated recipient passes the share
|
||||
ACL.
|
||||
- A copied active durable link can be inspected and installed without recipient sign-in.
|
||||
- Revocation immediately blocks new grants.
|
||||
- The installed receipt identifies the immutable package without persisting a grant.
|
||||
- Production provisioning changes are Terraform-owned and do not replace existing artifact,
|
||||
@@ -1376,7 +1396,7 @@ than delaying private sharing for organization-wide policy features.
|
||||
- One validated installer core, regardless of source or destination transport.
|
||||
- One canonical copy, with provider placements reconciled separately.
|
||||
- Immutable Cloud versions and mutable local installations are distinct concepts.
|
||||
- A durable share is authorization metadata, not a permanent blob URL.
|
||||
- An unpredictable durable share ID is a revocable bearer credential, not a permanent blob URL.
|
||||
- Installed bytes, not child-process exit codes, determine success.
|
||||
- Unknown or modified local state fails closed.
|
||||
- Remote hosts own their paths and mutations.
|
||||
|
||||
@@ -16,6 +16,7 @@ import { classifySkillCloudInstallTarget } from '../skills/skill-cloud-install-t
|
||||
import { SkillSharePreparationService } from '../skills/skill-share-preparation-service'
|
||||
import {
|
||||
supportsSkillRuntimeBundleInstall,
|
||||
supportsSkillRuntimeCancellation,
|
||||
supportsSkillRuntimeInstall
|
||||
} from '../skills/skill-runtime-capability'
|
||||
import { callRuntimeEnvironment } from './runtime-environment-transport-routing'
|
||||
@@ -40,17 +41,7 @@ const sharePrepareSchema = z
|
||||
const sharePublishSchema = z
|
||||
.object({
|
||||
preparationId: z.string().uuid(),
|
||||
releaseNotes: z.string().max(10_000),
|
||||
userIds: z.array(z.string().min(1).max(255)).max(100),
|
||||
shareWithOrganization: z.boolean()
|
||||
})
|
||||
.strict()
|
||||
|
||||
const replaceAccessSchema = z
|
||||
.object({
|
||||
packageId: z.string().min(1).max(128),
|
||||
userIds: z.array(z.string().min(1).max(255)).max(100),
|
||||
shareWithOrganization: z.boolean()
|
||||
releaseNotes: z.string().max(10_000)
|
||||
})
|
||||
.strict()
|
||||
|
||||
@@ -258,6 +249,9 @@ function registerCloudInstallHandlers(runtime: OrcaRuntimeService): void {
|
||||
return { cancelled: runtime.cancelSharedSkillInstall(input.operationId) }
|
||||
}
|
||||
const transferCancelled = remoteInstallCancellation.cancel(input.operationId)
|
||||
if (!(await supportsSkillRuntimeCancellation(app.getPath('userData'), input.environmentId))) {
|
||||
return { cancelled: transferCancelled }
|
||||
}
|
||||
const response = await callRuntimeEnvironment(
|
||||
app.getPath('userData'),
|
||||
input.environmentId,
|
||||
@@ -274,17 +268,7 @@ function registerCloudInstallHandlers(runtime: OrcaRuntimeService): void {
|
||||
ipcMain.handle('skills:getPackage', (_event, packageId: unknown) =>
|
||||
runtime.getSkillPackage(z.string().min(1).max(128).parse(packageId), {})
|
||||
)
|
||||
ipcMain.handle('skills:replacePackageAccess', (_event, value: unknown) => {
|
||||
const input = replaceAccessSchema.parse(value)
|
||||
return runtime.replaceSkillPackageAccess(
|
||||
input.packageId,
|
||||
{
|
||||
userIds: input.userIds,
|
||||
shareWithOrganization: input.shareWithOrganization
|
||||
},
|
||||
{}
|
||||
)
|
||||
})
|
||||
ipcMain.handle('skills:listOwnedShares', () => runtime.listOwnedSkillShares({}))
|
||||
ipcMain.handle('skills:revokeShare', (_event, shareId: unknown) =>
|
||||
runtime.revokeSkillShare(z.string().min(1).max(128).parse(shareId), {})
|
||||
)
|
||||
|
||||
@@ -4597,8 +4597,6 @@ export class OrcaRuntimeService {
|
||||
packageId: string,
|
||||
request: SkillCloudOptions & {
|
||||
pinnedVersionId?: string
|
||||
userIds: string[]
|
||||
shareWithOrganization: boolean
|
||||
idempotencyKey?: string
|
||||
}
|
||||
) {
|
||||
@@ -4641,12 +4639,8 @@ export class OrcaRuntimeService {
|
||||
return this.requireSkillCloudService().getPackage(packageId, options)
|
||||
}
|
||||
|
||||
replaceSkillPackageAccess(
|
||||
packageId: string,
|
||||
access: { userIds: string[]; shareWithOrganization: boolean },
|
||||
options: SkillCloudOptions
|
||||
): Promise<SkillCloudOperation<void>> {
|
||||
return this.requireSkillCloudService().replaceAccess(packageId, access, options)
|
||||
listOwnedSkillShares(options: SkillCloudOptions) {
|
||||
return this.requireSkillCloudService().listOwnedShares(options)
|
||||
}
|
||||
|
||||
revokeSkillShare(
|
||||
|
||||
@@ -21,6 +21,18 @@ describe('skillCloudRequest', () => {
|
||||
).resolves.toEqual({ share: { id: 'share_1' } })
|
||||
})
|
||||
|
||||
it('omits authorization for bearer-link requests', async () => {
|
||||
const fetcher = vi.fn(async (_input: URL | RequestInfo, init?: RequestInit) => {
|
||||
expect(new Headers(init?.headers).has('authorization')).toBe(false)
|
||||
return Response.json({ share: { id: 'share_1' } })
|
||||
}) as typeof fetch
|
||||
await skillCloudRequest({
|
||||
apiUrl: 'http://127.0.0.1:8787',
|
||||
path: '/v1/skill-shares/share_1',
|
||||
fetcher
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects non-API paths and returns structured errors without response contents', async () => {
|
||||
await expect(
|
||||
skillCloudRequest({
|
||||
|
||||
@@ -12,7 +12,7 @@ export class SkillCloudRequestError extends Error {
|
||||
|
||||
export async function skillCloudRequest<T>(input: {
|
||||
apiUrl?: string
|
||||
authToken: string
|
||||
authToken?: string
|
||||
path: string
|
||||
method?: 'DELETE' | 'GET' | 'POST' | 'PUT'
|
||||
body?: unknown
|
||||
@@ -28,7 +28,7 @@ export async function skillCloudRequest<T>(input: {
|
||||
const response = await (input.fetcher ?? fetch)(url, {
|
||||
method: input.method ?? 'GET',
|
||||
headers: {
|
||||
authorization: `Bearer ${input.authToken}`,
|
||||
...(input.authToken ? { authorization: `Bearer ${input.authToken}` } : {}),
|
||||
accept: 'application/json',
|
||||
...(input.body === undefined ? {} : { 'content-type': 'application/json' }),
|
||||
...(input.idempotencyKey ? { 'idempotency-key': input.idempotencyKey } : {})
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { SkillCloudService } from './skill-cloud-service'
|
||||
|
||||
vi.mock('electron', () => ({ app: { isPackaged: false } }))
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('SkillCloudService bearer links', () => {
|
||||
it('resolves and grants downloads without an Orca session', async () => {
|
||||
const requests: RequestInit[] = []
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (input: URL | RequestInfo, init?: RequestInit) => {
|
||||
requests.push(init ?? {})
|
||||
return String(input).endsWith('/download-grants')
|
||||
? Response.json({
|
||||
grant: { url: 'https://storage.test/package', expiresAt: '2026-08-11T00:05:00Z' },
|
||||
version: { versionId: 'ver_1' }
|
||||
})
|
||||
: Response.json({ share: { id: 'share_1', version: { versionId: 'ver_1' } } })
|
||||
})
|
||||
)
|
||||
const service = new SkillCloudService('/unused')
|
||||
const options = { apiUrl: 'http://127.0.0.1:8787' }
|
||||
|
||||
await expect(service.resolveShare('share_1', options)).resolves.toMatchObject({ status: 'ok' })
|
||||
await expect(service.createDownloadGrant('share_1', options)).resolves.toMatchObject({
|
||||
status: 'ok'
|
||||
})
|
||||
|
||||
expect(requests).toHaveLength(2)
|
||||
for (const request of requests) {
|
||||
expect(new Headers(request.headers).has('authorization')).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto'
|
||||
import type {
|
||||
SkillCloudDownloadGrant,
|
||||
SkillCloudInstallTarget,
|
||||
SkillCloudOwnedShare,
|
||||
SkillCloudOperation,
|
||||
SkillCloudOptions,
|
||||
SkillCloudPackageDetails,
|
||||
@@ -10,6 +11,7 @@ import type {
|
||||
SkillCloudShare,
|
||||
SkillCloudVersion
|
||||
} from '../../shared/skill-cloud-contract'
|
||||
import { resolveArtifactCloudApiUrl } from '../artifacts/artifact-cloud-config'
|
||||
import { runSkillCloudOperation } from './skill-cloud-auth'
|
||||
import { uploadSkillPackageToSignedPolicy } from './skill-cloud-direct-upload'
|
||||
import { skillCloudRequest } from './skill-cloud-request'
|
||||
@@ -97,8 +99,6 @@ export class SkillCloudService {
|
||||
packageId: string,
|
||||
request: SkillCloudOptions & {
|
||||
pinnedVersionId?: string
|
||||
userIds: string[]
|
||||
shareWithOrganization: boolean
|
||||
idempotencyKey?: string
|
||||
}
|
||||
): Promise<SkillCloudOperation<SkillCloudShare>> {
|
||||
@@ -109,9 +109,7 @@ export class SkillCloudService {
|
||||
path: `/v1/skill-packages/${id(packageId)}/shares`,
|
||||
method: 'POST',
|
||||
body: {
|
||||
pinnedVersionId: request.pinnedVersionId,
|
||||
userIds: request.userIds,
|
||||
shareWithOrganization: request.shareWithOrganization
|
||||
pinnedVersionId: request.pinnedVersionId
|
||||
},
|
||||
idempotencyKey: request.idempotencyKey ?? randomUUID()
|
||||
})
|
||||
@@ -123,10 +121,10 @@ export class SkillCloudService {
|
||||
shareId: string,
|
||||
options: SkillCloudOptions
|
||||
): Promise<SkillCloudOperation<{ id: string; version: SkillCloudVersion }>> {
|
||||
return this.withAuth(options, async (token, apiUrl) => {
|
||||
return this.withoutAuth(options, async (apiUrl) => {
|
||||
const result = await skillCloudRequest<{
|
||||
share: { id: string; version: SkillCloudVersion }
|
||||
}>({ apiUrl, authToken: token, path: `/v1/skill-shares/${id(shareId)}` })
|
||||
}>({ apiUrl, path: `/v1/skill-shares/${id(shareId)}` })
|
||||
return result.share
|
||||
})
|
||||
}
|
||||
@@ -135,10 +133,9 @@ export class SkillCloudService {
|
||||
shareId: string,
|
||||
options: SkillCloudOptions & { versionId?: string; installTarget?: SkillCloudInstallTarget }
|
||||
): Promise<SkillCloudOperation<SkillCloudDownloadGrant>> {
|
||||
return this.withAuth(options, (token, apiUrl) =>
|
||||
return this.withoutAuth(options, (apiUrl) =>
|
||||
skillCloudRequest<SkillCloudDownloadGrant>({
|
||||
apiUrl,
|
||||
authToken: token,
|
||||
path: `/v1/skill-shares/${id(shareId)}/download-grants`,
|
||||
method: 'POST',
|
||||
body: {
|
||||
@@ -179,21 +176,17 @@ export class SkillCloudService {
|
||||
})
|
||||
}
|
||||
|
||||
replaceAccess(
|
||||
packageId: string,
|
||||
access: { userIds: string[]; shareWithOrganization: boolean },
|
||||
listOwnedShares(
|
||||
options: SkillCloudOptions
|
||||
): Promise<SkillCloudOperation<void>> {
|
||||
return this.withAuth(options, (token, apiUrl) =>
|
||||
skillCloudRequest<void>({
|
||||
): Promise<SkillCloudOperation<SkillCloudOwnedShare[]>> {
|
||||
return this.withAuth(options, async (token, apiUrl) => {
|
||||
const result = await skillCloudRequest<{ shares: SkillCloudOwnedShare[] }>({
|
||||
apiUrl,
|
||||
authToken: token,
|
||||
path: `/v1/skill-packages/${id(packageId)}/access`,
|
||||
method: 'PUT',
|
||||
body: access,
|
||||
idempotencyKey: randomUUID()
|
||||
path: '/v1/skill-shares'
|
||||
})
|
||||
)
|
||||
return result.shares
|
||||
})
|
||||
}
|
||||
|
||||
revokeShare(shareId: string, options: SkillCloudOptions): Promise<SkillCloudOperation<void>> {
|
||||
@@ -243,4 +236,12 @@ export class SkillCloudService {
|
||||
operation
|
||||
})
|
||||
}
|
||||
|
||||
private async withoutAuth<T>(
|
||||
options: SkillCloudOptions,
|
||||
operation: (apiUrl: string) => Promise<T>
|
||||
): Promise<SkillCloudOperation<T>> {
|
||||
const value = await operation(resolveArtifactCloudApiUrl(options.apiUrl))
|
||||
return { status: 'ok', value }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getRuntimeEnvironmentStatus } from '../ipc/runtime-environment-transport-routing'
|
||||
import {
|
||||
supportsSkillRuntimeCancellation,
|
||||
supportsSkillRuntimeInstall,
|
||||
supportsSkillRuntimeManagement
|
||||
} from './skill-runtime-capability'
|
||||
@@ -29,6 +30,21 @@ describe('skill runtime capability admission', () => {
|
||||
await expect(supportsSkillRuntimeManagement('/state', 'environment_1')).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('admits cancellation only when the host advertises it', async () => {
|
||||
status
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: { capabilities: ['skills.install.v1'], runtimeId: 'runtime_1' }
|
||||
} as never)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: { capabilities: ['skills.install-cancel.v1'], runtimeId: 'runtime_1' }
|
||||
} as never)
|
||||
|
||||
await expect(supportsSkillRuntimeCancellation('/state', 'environment_1')).resolves.toBe(false)
|
||||
await expect(supportsSkillRuntimeCancellation('/state', 'environment_1')).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('fails closed for unavailable and capability-omitting hosts', async () => {
|
||||
status
|
||||
.mockResolvedValueOnce({ ok: false, error: { code: 'offline', message: 'offline' } } as never)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
SKILL_BUNDLE_INSTALL_CAPABILITY,
|
||||
SKILL_INSTALL_CANCEL_CAPABILITY,
|
||||
SKILL_INSTALL_CAPABILITY,
|
||||
SKILL_MANAGEMENT_CAPABILITY
|
||||
} from '../../shared/skill-install-capability'
|
||||
@@ -26,6 +27,17 @@ export async function supportsSkillRuntimeBundleInstall(
|
||||
)
|
||||
}
|
||||
|
||||
export async function supportsSkillRuntimeCancellation(
|
||||
userDataPath: string,
|
||||
environmentId: string
|
||||
): Promise<boolean> {
|
||||
const status = await getRuntimeEnvironmentStatus(userDataPath, environmentId, 15_000)
|
||||
return (
|
||||
status.ok === true &&
|
||||
status.result.capabilities?.includes(SKILL_INSTALL_CANCEL_CAPABILITY) === true
|
||||
)
|
||||
}
|
||||
|
||||
export async function supportsSkillRuntimeInstall(
|
||||
userDataPath: string,
|
||||
environmentId: string
|
||||
|
||||
@@ -70,9 +70,7 @@ describe('SkillSharePreparationService', () => {
|
||||
const preview = await service.prepare({ sourceDirectory: source })
|
||||
const input = {
|
||||
preparationId: preview.preparationId,
|
||||
releaseNotes: 'retry',
|
||||
userIds: ['user_2'],
|
||||
shareWithOrganization: false
|
||||
releaseNotes: 'retry'
|
||||
}
|
||||
|
||||
await expect(service.publish(input)).rejects.toThrow('response lost')
|
||||
@@ -88,18 +86,18 @@ describe('SkillSharePreparationService', () => {
|
||||
expect(idempotencyKey).toBe(createShare.mock.calls[1]?.[1].idempotencyKey)
|
||||
})
|
||||
|
||||
it('keeps the finalized version when the audience must be corrected', async () => {
|
||||
it('keeps the finalized version while sign-in is reconnected', async () => {
|
||||
const { root, source } = await createSource()
|
||||
const publishVersion = vi.fn(async (request: { packageId: string }) => ({
|
||||
status: 'ok' as const,
|
||||
value: publishedVersion(request.packageId, 'version_audience')
|
||||
value: publishedVersion(request.packageId, 'version_reconnect')
|
||||
}))
|
||||
const createShare = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('skill_package_organization_required'))
|
||||
.mockResolvedValueOnce({ status: 'reconnect-required' })
|
||||
.mockResolvedValueOnce({
|
||||
status: 'ok',
|
||||
value: { id: 'share_people', url: 'https://share.test/skills/share/share_people' }
|
||||
value: { id: 'share_retry', url: 'https://share.test/skills/share/share_retry' }
|
||||
})
|
||||
const service = new SkillSharePreparationService(join(root, 'preparations'), {
|
||||
publishVersion,
|
||||
@@ -108,24 +106,14 @@ describe('SkillSharePreparationService', () => {
|
||||
const preview = await service.prepare({ sourceDirectory: source })
|
||||
|
||||
await expect(
|
||||
service.publish({
|
||||
preparationId: preview.preparationId,
|
||||
releaseNotes: 'retry',
|
||||
userIds: [],
|
||||
shareWithOrganization: true
|
||||
})
|
||||
).rejects.toThrow('skill_package_organization_required')
|
||||
service.publish({ preparationId: preview.preparationId, releaseNotes: 'retry' })
|
||||
).resolves.toEqual({ status: 'reconnect-required' })
|
||||
await expect(
|
||||
service.publish({
|
||||
preparationId: preview.preparationId,
|
||||
releaseNotes: 'retry',
|
||||
userIds: ['user_2'],
|
||||
shareWithOrganization: false
|
||||
})
|
||||
).resolves.toMatchObject({ status: 'ok', value: { share: { id: 'share_people' } } })
|
||||
service.publish({ preparationId: preview.preparationId, releaseNotes: 'retry' })
|
||||
).resolves.toMatchObject({ status: 'ok', value: { share: { id: 'share_retry' } } })
|
||||
|
||||
expect(publishVersion).toHaveBeenCalledOnce()
|
||||
expect(createShare.mock.calls[0]?.[1].idempotencyKey).not.toBe(
|
||||
expect(createShare.mock.calls[0]?.[1].idempotencyKey).toBe(
|
||||
createShare.mock.calls[1]?.[1].idempotencyKey
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdir, rm } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import type {
|
||||
@@ -21,13 +21,8 @@ type Preparation = {
|
||||
publishedVersion: SkillCloudVersion | null
|
||||
}
|
||||
|
||||
function shareIdempotencyKey(preparationId: string, input: SkillSharePublishInput): string {
|
||||
const audience = JSON.stringify({
|
||||
shareWithOrganization: input.shareWithOrganization,
|
||||
userIds: [...new Set(input.userIds)].sort()
|
||||
})
|
||||
const digest = createHash('sha256').update(audience).digest('hex').slice(0, 32)
|
||||
return `${preparationId}_${digest}`
|
||||
function shareIdempotencyKey(preparationId: string): string {
|
||||
return `${preparationId}_share`
|
||||
}
|
||||
|
||||
function preview(id: string, value: Preparation): SkillSharePreview {
|
||||
@@ -133,8 +128,6 @@ export class SkillSharePreparationService {
|
||||
compressedBytes: preparation.created.compressedBytes,
|
||||
packageId: preparation.created.manifest.packageId,
|
||||
releaseNotes: input.releaseNotes,
|
||||
userIds: input.userIds,
|
||||
shareWithOrganization: input.shareWithOrganization,
|
||||
signal: controller.signal,
|
||||
onProgress: (progress) =>
|
||||
onProgress?.({ preparationId: input.preparationId, ...progress })
|
||||
@@ -146,9 +139,7 @@ export class SkillSharePreparationService {
|
||||
preparation.publishedVersion = version
|
||||
}
|
||||
const shared = await this.cloud.createShare(version.packageId, {
|
||||
userIds: input.userIds,
|
||||
shareWithOrganization: input.shareWithOrganization,
|
||||
idempotencyKey: shareIdempotencyKey(input.preparationId, input)
|
||||
idempotencyKey: shareIdempotencyKey(input.preparationId)
|
||||
})
|
||||
if (shared.status !== 'ok') {
|
||||
return shared
|
||||
|
||||
@@ -403,6 +403,7 @@ import type {
|
||||
import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../shared/skills'
|
||||
import type {
|
||||
SkillCloudDownloadGrant,
|
||||
SkillCloudOwnedShare,
|
||||
SkillCloudOperation,
|
||||
SkillCloudPackageDetails
|
||||
} from '../shared/skill-cloud-contract'
|
||||
@@ -2602,11 +2603,7 @@ export type PreloadApi = {
|
||||
removeInstall: (input: SkillRemoveInput) => Promise<SkillRemoveOperation>
|
||||
listManagedInstalls: (environmentId?: string) => Promise<ManagedSkillInstallListOperation>
|
||||
getPackage: (packageId: string) => Promise<SkillCloudOperation<SkillCloudPackageDetails>>
|
||||
replacePackageAccess: (input: {
|
||||
packageId: string
|
||||
userIds: string[]
|
||||
shareWithOrganization: boolean
|
||||
}) => Promise<SkillCloudOperation<void>>
|
||||
listOwnedShares: () => Promise<SkillCloudOperation<SkillCloudOwnedShare[]>>
|
||||
revokeShare: (shareId: string) => Promise<SkillCloudOperation<void>>
|
||||
deletePackageVersion: (input: {
|
||||
packageId: string
|
||||
|
||||
@@ -131,6 +131,7 @@ import type {
|
||||
import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../shared/skills'
|
||||
import type {
|
||||
SkillCloudDownloadGrant,
|
||||
SkillCloudOwnedShare,
|
||||
SkillCloudOperation,
|
||||
SkillCloudPackageDetails
|
||||
} from '../shared/skill-cloud-contract'
|
||||
@@ -2567,12 +2568,8 @@ const api = {
|
||||
ipcRenderer.invoke('skills:listManagedInstalls', environmentId),
|
||||
getPackage: (packageId: string): Promise<SkillCloudOperation<SkillCloudPackageDetails>> =>
|
||||
ipcRenderer.invoke('skills:getPackage', packageId),
|
||||
replacePackageAccess: (input: {
|
||||
packageId: string
|
||||
userIds: string[]
|
||||
shareWithOrganization: boolean
|
||||
}): Promise<SkillCloudOperation<void>> =>
|
||||
ipcRenderer.invoke('skills:replacePackageAccess', input),
|
||||
listOwnedShares: (): Promise<SkillCloudOperation<SkillCloudOwnedShare[]>> =>
|
||||
ipcRenderer.invoke('skills:listOwnedShares'),
|
||||
revokeShare: (shareId: string): Promise<SkillCloudOperation<void>> =>
|
||||
ipcRenderer.invoke('skills:revokeShare', shareId),
|
||||
deletePackageVersion: (input: {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { OwnedSkillShareLinks } from './OwnedSkillShareLinks'
|
||||
|
||||
vi.mock('@/i18n/i18n', () => ({
|
||||
translate: (_key: string, fallback: string) => fallback
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { success: vi.fn() } }))
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
Reflect.deleteProperty(window, 'api')
|
||||
})
|
||||
|
||||
describe('OwnedSkillShareLinks', () => {
|
||||
it('copies and revokes owner-only shared links', async () => {
|
||||
const writeClipboardText = vi.fn().mockResolvedValue(undefined)
|
||||
const revokeShare = vi.fn().mockResolvedValue({ status: 'ok', value: undefined })
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: {
|
||||
ui: { writeClipboardText },
|
||||
skills: {
|
||||
listOwnedShares: vi.fn().mockResolvedValue({
|
||||
status: 'ok',
|
||||
value: [
|
||||
{
|
||||
id: 'share_1',
|
||||
url: 'https://share.onorca.dev/skills/share/share_1',
|
||||
packageId: 'package_1',
|
||||
name: 'team-skills',
|
||||
description: 'Team bundle',
|
||||
createdAt: '2026-08-12T00:00:00.000Z'
|
||||
}
|
||||
]
|
||||
}),
|
||||
revokeShare
|
||||
}
|
||||
}
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
render(<OwnedSkillShareLinks />)
|
||||
|
||||
await screen.findByText('team-skills')
|
||||
await user.click(screen.getByRole('button', { name: 'Copy link' }))
|
||||
expect(writeClipboardText).toHaveBeenCalledWith('https://share.onorca.dev/skills/share/share_1')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Unshare' }))
|
||||
expect(revokeShare).not.toHaveBeenCalled()
|
||||
await user.click(screen.getByRole('button', { name: 'Confirm unshare' }))
|
||||
await waitFor(() => expect(revokeShare).toHaveBeenCalledWith('share_1'))
|
||||
expect(screen.queryByText('team-skills')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,178 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Clipboard, Link2Off, Loader2, RefreshCw } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import type { SkillCloudOwnedShare } from '../../../../shared/skill-cloud-contract'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
function inventoryError(status: string): string {
|
||||
return status === 'reconnect-required'
|
||||
? translate(
|
||||
'auto.components.settings.shareSkills.linksReconnect',
|
||||
'Sign in again to manage shared links.'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.settings.shareSkills.linksUnavailable',
|
||||
'Shared links are unavailable right now.'
|
||||
)
|
||||
}
|
||||
|
||||
export function OwnedSkillShareLinks(): React.JSX.Element {
|
||||
const [shares, setShares] = useState<SkillCloudOwnedShare[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [busyShareId, setBusyShareId] = useState<string | null>(null)
|
||||
const [confirmShareId, setConfirmShareId] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const generation = useRef(0)
|
||||
|
||||
const load = useCallback(async (): Promise<void> => {
|
||||
const current = ++generation.current
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const operation = await window.api.skills.listOwnedShares()
|
||||
if (generation.current !== current) {
|
||||
return
|
||||
}
|
||||
if (operation.status !== 'ok') {
|
||||
setError(inventoryError(operation.status))
|
||||
return
|
||||
}
|
||||
setShares(operation.value)
|
||||
} catch {
|
||||
if (generation.current === current) {
|
||||
setError(
|
||||
translate(
|
||||
'auto.components.settings.shareSkills.linksUnavailable',
|
||||
'Shared links are unavailable right now.'
|
||||
)
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
if (generation.current === current) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
return () => {
|
||||
generation.current += 1
|
||||
}
|
||||
}, [load])
|
||||
|
||||
const copy = async (share: SkillCloudOwnedShare): Promise<void> => {
|
||||
await window.api.ui.writeClipboardText(share.url)
|
||||
toast.success(translate('auto.components.settings.shareSkills.linkCopied', 'Share link copied'))
|
||||
}
|
||||
|
||||
const unshare = async (share: SkillCloudOwnedShare): Promise<void> => {
|
||||
if (confirmShareId !== share.id) {
|
||||
setConfirmShareId(share.id)
|
||||
return
|
||||
}
|
||||
setBusyShareId(share.id)
|
||||
setError(null)
|
||||
try {
|
||||
const operation = await window.api.skills.revokeShare(share.id)
|
||||
if (operation.status !== 'ok') {
|
||||
setError(inventoryError(operation.status))
|
||||
return
|
||||
}
|
||||
setShares((current) => current.filter((candidate) => candidate.id !== share.id))
|
||||
setConfirmShareId(null)
|
||||
toast.success(translate('auto.components.settings.shareSkills.linkRevoked', 'Link revoked'))
|
||||
} catch {
|
||||
setError(
|
||||
translate(
|
||||
'auto.components.settings.shareSkills.revokeFailed',
|
||||
'Orca could not revoke this link.'
|
||||
)
|
||||
)
|
||||
} finally {
|
||||
setBusyShareId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-3 py-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium">
|
||||
{translate('auto.components.settings.shareSkills.activeLinks', 'Active shared links')}
|
||||
</h3>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.settings.shareSkills.activeLinksDescription',
|
||||
'Only people with a link can open it. Unshare a link to block future inspection and installs.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={loading}
|
||||
onClick={() => void load()}
|
||||
>
|
||||
{loading ? <Loader2 className="size-4 animate-spin" /> : <RefreshCw className="size-4" />}
|
||||
{translate('auto.components.settings.shareSkills.refreshLinks', 'Refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!loading && shares.length === 0 && !error ? (
|
||||
<p className="rounded-md border border-border p-3 text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.settings.shareSkills.noActiveLinks',
|
||||
'No active links. Publish a skill bundle from Skills to create one.'
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{shares.length > 0 ? (
|
||||
<ul className="space-y-2">
|
||||
{shares.map((share) => (
|
||||
<li key={share.id} className="space-y-2 rounded-md border border-border p-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">{share.name}</p>
|
||||
<p className="truncate font-mono text-xs text-muted-foreground">{share.url}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => void copy(share)}>
|
||||
<Clipboard className="size-4" />
|
||||
{translate('auto.components.settings.shareSkills.copyLink', 'Copy link')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={confirmShareId === share.id ? 'destructive' : 'outline'}
|
||||
disabled={busyShareId !== null}
|
||||
onClick={() => void unshare(share)}
|
||||
>
|
||||
{busyShareId === share.id ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Link2Off className="size-4" />
|
||||
)}
|
||||
{confirmShareId === share.id
|
||||
? translate(
|
||||
'auto.components.settings.shareSkills.confirmUnshare',
|
||||
'Confirm unshare'
|
||||
)
|
||||
: translate('auto.components.settings.shareSkills.unshare', 'Unshare')}
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<p className="text-xs text-destructive" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -55,6 +55,7 @@ import { PluginsSettingsSection } from './PluginsSettingsSection'
|
||||
import { AgentsPane } from './AgentsPane'
|
||||
import { OrchestrationPane } from './OrchestrationPane'
|
||||
import { ArtifactsSettingsPane } from './ArtifactsSettingsPane'
|
||||
import { ShareSkillsSettingsPane } from './ShareSkillsSettingsPane'
|
||||
import { AutomationsSettingsPane } from './AutomationsSettingsPane'
|
||||
import { OrcaAccountSettingsPane } from './OrcaAccountSettingsPane'
|
||||
import { LinearAgentSkillPane } from './LinearAgentSkillPane'
|
||||
@@ -1456,6 +1457,19 @@ function Settings(): React.JSX.Element {
|
||||
) : null}
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
id="share-skills"
|
||||
title={translate('auto.components.settings.shareSkills.title', 'Share Skills')}
|
||||
badge="Beta"
|
||||
description={translate(
|
||||
'auto.components.settings.shareSkills.description',
|
||||
'Share one or many skills through unlisted, revocable links.'
|
||||
)}
|
||||
searchEntries={getSectionSearchEntries('share-skills')}
|
||||
>
|
||||
{isSectionMounted('share-skills') ? <ShareSkillsSettingsPane /> : null}
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
id="git"
|
||||
title={translate(
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
connect: vi.fn(),
|
||||
fetchAuthStatus: vi.fn(),
|
||||
openSkillsPage: vi.fn(),
|
||||
state: {
|
||||
orcaProfileAuthStatus: { configured: true, state: 'connected' } as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null,
|
||||
orcaProfileConnecting: false,
|
||||
isWebClient: false
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n/i18n', () => ({
|
||||
translate: (_key: string, fallback: string) => fallback
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/web-client-location', () => ({
|
||||
isWebClientLocation: () => mocks.state.isWebClient
|
||||
}))
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: (selector: (state: Record<string, unknown>) => unknown) =>
|
||||
selector({
|
||||
...mocks.state,
|
||||
connectCurrentOrcaProfile: mocks.connect,
|
||||
fetchOrcaProfileAuthStatus: mocks.fetchAuthStatus,
|
||||
openSkillsPage: mocks.openSkillsPage
|
||||
})
|
||||
}))
|
||||
|
||||
import { ShareSkillsSettingsPane } from './ShareSkillsSettingsPane'
|
||||
|
||||
describe('ShareSkillsSettingsPane', () => {
|
||||
beforeEach(() => {
|
||||
mocks.connect.mockReset()
|
||||
mocks.fetchAuthStatus.mockReset()
|
||||
mocks.openSkillsPage.mockReset()
|
||||
mocks.state.orcaProfileAuthStatus = { configured: true, state: 'connected' }
|
||||
mocks.state.orcaProfileConnecting = false
|
||||
mocks.state.isWebClient = false
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: {
|
||||
skills: {
|
||||
listOwnedShares: vi.fn().mockResolvedValue({ status: 'ok', value: [] }),
|
||||
revokeShare: vi.fn()
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
Reflect.deleteProperty(window, 'api')
|
||||
})
|
||||
|
||||
it('explains unlisted multi-skill links and opens Skills', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ShareSkillsSettingsPane />)
|
||||
|
||||
expect(screen.getByText('Unlisted skill links')).toBeInTheDocument()
|
||||
expect(screen.getByText('Select one or more skills')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Anyone with the link can inspect and install all or selected skills without signing in.'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText(/installed on another machine remain there/)).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: /Open Skills/ }))
|
||||
expect(mocks.openSkillsPage).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('offers owner sign-in while explaining recipients stay signed out', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.state.orcaProfileAuthStatus = { configured: true, state: 'local' }
|
||||
render(<ShareSkillsSettingsPane />)
|
||||
|
||||
expect(screen.getByText('Sign in to share skills')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Recipients do not need an account/)).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'Sign in to Orca' }))
|
||||
expect(mocks.connect).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not offer desktop publishing from the web client', () => {
|
||||
mocks.state.isWebClient = true
|
||||
mocks.state.orcaProfileAuthStatus = { configured: true, state: 'local' }
|
||||
render(<ShareSkillsSettingsPane />)
|
||||
|
||||
expect(screen.getByText(/available in the Orca desktop app/)).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /Open Skills/ })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Sign in to Orca' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,175 @@
|
||||
import { useEffect } from 'react'
|
||||
import { ArrowRight, Share2 } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { isWebClientLocation } from '@/lib/web-client-location'
|
||||
import { useAppStore } from '@/store'
|
||||
import { OwnedSkillShareLinks } from './OwnedSkillShareLinks'
|
||||
|
||||
type HowToStep = { key: string; title: string; description: string }
|
||||
|
||||
export function ShareSkillsSettingsPane(): React.JSX.Element {
|
||||
const openSkillsPage = useAppStore((state) => state.openSkillsPage)
|
||||
const authStatus = useAppStore((state) => state.orcaProfileAuthStatus)
|
||||
const connecting = useAppStore((state) => state.orcaProfileConnecting)
|
||||
const connect = useAppStore((state) => state.connectCurrentOrcaProfile)
|
||||
const fetchAuthStatus = useAppStore((state) => state.fetchOrcaProfileAuthStatus)
|
||||
const signedIn = authStatus?.state === 'connected'
|
||||
const isWebClient = isWebClientLocation()
|
||||
|
||||
useEffect(() => {
|
||||
if (!authStatus) {
|
||||
void fetchAuthStatus()
|
||||
}
|
||||
}, [authStatus, fetchAuthStatus])
|
||||
|
||||
const steps: HowToStep[] = [
|
||||
{
|
||||
key: 'select',
|
||||
title: translate(
|
||||
'auto.components.settings.shareSkills.selectTitle',
|
||||
'Select one or more skills'
|
||||
),
|
||||
description: translate(
|
||||
'auto.components.settings.shareSkills.selectDescription',
|
||||
'Open Skills, choose Share skills, and select the skills to bundle behind one link.'
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'review',
|
||||
title: translate('auto.components.settings.shareSkills.reviewTitle', 'Review and publish'),
|
||||
description: translate(
|
||||
'auto.components.settings.shareSkills.reviewDescription',
|
||||
'Review included files, scripts, and executables before uploading the immutable bundle.'
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'share',
|
||||
title: translate('auto.components.settings.shareSkills.copyTitle', 'Copy the unlisted link'),
|
||||
description: translate(
|
||||
'auto.components.settings.shareSkills.copyDescription',
|
||||
'Anyone with the link can inspect and install all or selected skills without signing in.'
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'manage',
|
||||
title: translate(
|
||||
'auto.components.settings.shareSkills.manageTitle',
|
||||
'Manage or revoke links'
|
||||
),
|
||||
description: translate(
|
||||
'auto.components.settings.shareSkills.manageDescription',
|
||||
'Revoking blocks future access. Skills already installed on another machine remain there.'
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-border">
|
||||
<section className="space-y-2 py-5 first:pt-0">
|
||||
<h3 className="text-sm font-medium">
|
||||
{translate('auto.components.settings.shareSkills.linkTitle', 'Unlisted skill links')}
|
||||
</h3>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.settings.shareSkills.linkDescription',
|
||||
'Shared bundles are not searchable or listed in Orca. The link is the credential, so send it only to people you trust.'
|
||||
)}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{!signedIn ? (
|
||||
<section className="flex flex-wrap items-center gap-4 py-5">
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<h3 className="text-sm font-medium">
|
||||
{translate(
|
||||
'auto.components.settings.shareSkills.signInTitle',
|
||||
'Sign in to share skills'
|
||||
)}
|
||||
</h3>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{isWebClient
|
||||
? translate(
|
||||
'auto.components.settings.shareSkills.signInWebDescription',
|
||||
'Publishing and link management are available in the Orca desktop app.'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.settings.shareSkills.signInDescription',
|
||||
'Use your Orca account to publish bundles and manage their links. Recipients do not need an account.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{!isWebClient ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={connecting || authStatus?.configured !== true}
|
||||
onClick={() => void connect()}
|
||||
>
|
||||
{connecting
|
||||
? translate('auto.components.settings.shareSkills.signingIn', 'Signing in…')
|
||||
: authStatus?.state === 'reconnect-required'
|
||||
? translate('auto.components.settings.shareSkills.signInAgain', 'Sign in again')
|
||||
: translate('auto.components.settings.shareSkills.signIn', 'Sign in to Orca')}
|
||||
</Button>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{signedIn && !isWebClient ? <OwnedSkillShareLinks /> : null}
|
||||
|
||||
<section className="space-y-4 py-5">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium">
|
||||
{translate('auto.components.settings.shareSkills.howToTitle', 'How to share skills')}
|
||||
</h3>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.settings.shareSkills.howToDescription',
|
||||
'Publish one skill or a bundle such as a collection of 30 skills behind one link.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ol className="space-y-3">
|
||||
{steps.map((step, index) => (
|
||||
<li key={step.key} className="flex items-start gap-3">
|
||||
<span className="flex size-6 shrink-0 items-center justify-center rounded-full border border-border/60 bg-muted/30 text-[11px] font-semibold text-muted-foreground">
|
||||
{index + 1}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 space-y-0.5">
|
||||
<p className="text-sm font-medium">{step.title}</p>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">{step.description}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
{!isWebClient ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="h-auto w-full justify-start whitespace-normal rounded-md border border-border/60 bg-muted/20 px-4 py-3 text-left hover:bg-muted/35 hover:text-foreground"
|
||||
onClick={openSkillsPage}
|
||||
>
|
||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-md border border-border bg-background text-muted-foreground">
|
||||
<Share2 className="size-4" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 space-y-0.5">
|
||||
<span className="block text-sm font-medium text-foreground">
|
||||
{translate('auto.components.settings.shareSkills.openSkills', 'Open Skills')}
|
||||
</span>
|
||||
<span className="block text-xs font-normal text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.settings.shareSkills.openSkillsDescription',
|
||||
'Publish a bundle, install from a link, or manage installed and shared skills.'
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<ArrowRight className="ml-auto size-4 shrink-0 text-muted-foreground" />
|
||||
</Button>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { createLocalizedCatalog } from '@/i18n/localized-catalog'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { translateSearchKeyword } from './settings-search-keywords'
|
||||
|
||||
export const getShareSkillsSettingsSearchEntries = createLocalizedCatalog(() => [
|
||||
{
|
||||
title: translate('auto.components.settings.shareSkills.linkTitle', 'Unlisted skill links'),
|
||||
description: translate(
|
||||
'auto.components.settings.shareSkills.searchDescription',
|
||||
'Share one or more skills behind an unlisted, revocable link.'
|
||||
),
|
||||
keywords: [
|
||||
...translateSearchKeyword('auto.components.settings.shareSkills.keywordSkills', 'skills'),
|
||||
...translateSearchKeyword('auto.components.settings.shareSkills.keywordShare', 'share'),
|
||||
...translateSearchKeyword('auto.components.settings.shareSkills.keywordBundle', 'bundle'),
|
||||
...translateSearchKeyword('auto.components.settings.shareSkills.keywordLink', 'link'),
|
||||
...translateSearchKeyword('auto.components.settings.shareSkills.keywordUnlisted', 'unlisted'),
|
||||
...translateSearchKeyword('auto.components.settings.shareSkills.keywordRevoke', 'revoke')
|
||||
]
|
||||
}
|
||||
])
|
||||
@@ -13,11 +13,10 @@ const details: SkillCloudPackageDetails = {
|
||||
canManage: true,
|
||||
versions: [],
|
||||
management: {
|
||||
userIds: ['legacy_user'],
|
||||
shareWithOrganization: false,
|
||||
shares: [
|
||||
{
|
||||
id: 'share_1',
|
||||
url: 'https://share.test/skills/share/share_1',
|
||||
pinnedVersionId: 'ver_1',
|
||||
createdAt: '2026-08-11T00:00:00.000Z'
|
||||
}
|
||||
@@ -27,35 +26,18 @@ const details: SkillCloudPackageDetails = {
|
||||
|
||||
function setup() {
|
||||
const skills = {
|
||||
replacePackageAccess: vi.fn().mockResolvedValue({ status: 'ok', value: undefined }),
|
||||
revokeShare: vi.fn().mockResolvedValue({ status: 'ok', value: undefined }),
|
||||
deletePackageVersion: vi.fn().mockResolvedValue({ status: 'ok', value: undefined }),
|
||||
deletePackage: vi.fn().mockResolvedValue({ status: 'ok', value: undefined })
|
||||
}
|
||||
const writeClipboardText = vi.fn().mockResolvedValue(undefined)
|
||||
const onChanged = vi.fn().mockResolvedValue(undefined)
|
||||
const onPackageDeleted = vi.fn()
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: {
|
||||
skills,
|
||||
orcaProfiles: {
|
||||
authStatus: vi.fn().mockResolvedValue({
|
||||
cloud: { userId: 'viewer', activeOrgId: 'org_1' }
|
||||
}),
|
||||
orgMembersList: vi.fn().mockResolvedValue({
|
||||
status: 'ok',
|
||||
roster: {
|
||||
members: [
|
||||
{
|
||||
userId: 'user_2',
|
||||
email: 'teammate@example.com',
|
||||
displayName: 'Teammate',
|
||||
role: 'member'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
}
|
||||
ui: { writeClipboardText }
|
||||
}
|
||||
})
|
||||
render(
|
||||
@@ -66,7 +48,7 @@ function setup() {
|
||||
onPackageDeleted={onPackageDeleted}
|
||||
/>
|
||||
)
|
||||
return { skills, onChanged, onPackageDeleted }
|
||||
return { skills, onChanged, onPackageDeleted, writeClipboardText }
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
@@ -76,26 +58,10 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('SkillCloudManagementActions', () => {
|
||||
it('saves organization and selected-user access while preserving unknown recipients', async () => {
|
||||
const { skills, onChanged } = setup()
|
||||
const organization = await screen.findByRole('checkbox', { name: 'Current organization' })
|
||||
await waitFor(() => expect((organization as HTMLButtonElement).disabled).toBe(false))
|
||||
fireEvent.click(organization)
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Teammate' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save access' }))
|
||||
|
||||
await waitFor(() => expect(skills.replacePackageAccess).toHaveBeenCalledOnce())
|
||||
expect(skills.replacePackageAccess).toHaveBeenCalledWith({
|
||||
packageId: 'pkg_1',
|
||||
userIds: ['legacy_user', 'user_2'],
|
||||
shareWithOrganization: true
|
||||
})
|
||||
expect(onChanged).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('requires confirmation before revoking an active link', async () => {
|
||||
const { skills } = setup()
|
||||
expect(screen.getByText(/Copies already installed on any machine remain there\./)).toBeTruthy()
|
||||
expect(screen.getByText(/leaves installed copies unchanged/)).toBeTruthy()
|
||||
expect(screen.queryByText('Access')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Unshare' }))
|
||||
expect(skills.revokeShare).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Confirm unshare' }))
|
||||
@@ -103,6 +69,14 @@ describe('SkillCloudManagementActions', () => {
|
||||
await waitFor(() => expect(skills.revokeShare).toHaveBeenCalledWith('share_1'))
|
||||
})
|
||||
|
||||
it('copies an active unlisted link', async () => {
|
||||
const { writeClipboardText } = setup()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Copy link' }))
|
||||
await waitFor(() =>
|
||||
expect(writeClipboardText).toHaveBeenCalledWith('https://share.test/skills/share/share_1')
|
||||
)
|
||||
})
|
||||
|
||||
it('requires confirmation before deleting the selected immutable version', async () => {
|
||||
const { skills } = setup()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete selected Cloud version' }))
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Loader2, Link2Off, Save, Trash2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { Clipboard, Link2Off, Trash2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import type { OrcaOrgMember } from '../../../../shared/orca-profiles'
|
||||
import type {
|
||||
SkillCloudOperation,
|
||||
SkillCloudPackageDetails
|
||||
} from '../../../../shared/skill-cloud-contract'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
type IdentifiedMember = OrcaOrgMember & { userId: string }
|
||||
type SkillCloudManagement = NonNullable<SkillCloudPackageDetails['management']>
|
||||
type SkillCloudManagementActionsProps = {
|
||||
details: SkillCloudPackageDetails
|
||||
@@ -35,12 +33,7 @@ export function SkillCloudManagementActions(
|
||||
if (!props.details.canManage || !management) {
|
||||
return null
|
||||
}
|
||||
const stateKey = JSON.stringify([
|
||||
props.details.id,
|
||||
management.userIds,
|
||||
management.shareWithOrganization,
|
||||
management.shares
|
||||
])
|
||||
const stateKey = JSON.stringify([props.details.id, management.shares])
|
||||
return <SkillCloudManagementForm key={stateKey} {...props} management={management} />
|
||||
}
|
||||
|
||||
@@ -53,48 +46,22 @@ function SkillCloudManagementForm({
|
||||
}: SkillCloudManagementActionsProps & {
|
||||
management: SkillCloudManagement
|
||||
}): React.JSX.Element {
|
||||
const [members, setMembers] = useState<IdentifiedMember[]>([])
|
||||
const [organizationAvailable, setOrganizationAvailable] = useState(false)
|
||||
const [userIds, setUserIds] = useState<string[]>(management?.userIds ?? [])
|
||||
const [shareWithOrganization, setShareWithOrganization] = useState(
|
||||
management?.shareWithOrganization ?? false
|
||||
)
|
||||
const [busyAction, setBusyAction] = useState<string | null>(null)
|
||||
const [confirmation, setConfirmation] = useState<string | null>(null)
|
||||
const [message, setMessage] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
setOrganizationAvailable(false)
|
||||
setMembers([])
|
||||
void window.api.orcaProfiles
|
||||
.authStatus()
|
||||
.then(async (auth) => {
|
||||
const orgId = auth.cloud?.activeOrgId
|
||||
if (!active || !orgId) {
|
||||
return
|
||||
}
|
||||
setOrganizationAvailable(true)
|
||||
const result = await window.api.orcaProfiles.orgMembersList({ orgId })
|
||||
if (active && result.status === 'ok') {
|
||||
setMembers(
|
||||
result.roster.members.filter(
|
||||
(member): member is IdentifiedMember =>
|
||||
Boolean(member.userId) && member.userId !== auth.cloud?.userId
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
return () => {
|
||||
active = false
|
||||
const copyShareLink = async (url?: string): Promise<void> => {
|
||||
if (!url) {
|
||||
return
|
||||
}
|
||||
}, [])
|
||||
|
||||
const unknownUserCount = useMemo(
|
||||
() => userIds.filter((id) => !members.some((member) => member.userId === id)).length,
|
||||
[members, userIds]
|
||||
)
|
||||
await window.api.ui.writeClipboardText(url)
|
||||
toast.success(
|
||||
translate(
|
||||
'auto.components.skills.SkillCloudManagementActions.linkCopied',
|
||||
'Share link copied'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const run = async (
|
||||
action: string,
|
||||
@@ -131,77 +98,6 @@ function SkillCloudManagementForm({
|
||||
'Cloud sharing controls'
|
||||
)}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-semibold">
|
||||
{translate('auto.components.skills.SkillCloudManagementActions.2552c12fea', 'Access')}
|
||||
</h4>
|
||||
<label className="flex items-center gap-2 text-xs">
|
||||
<Checkbox
|
||||
checked={shareWithOrganization}
|
||||
disabled={!organizationAvailable || busyAction !== null}
|
||||
onCheckedChange={(checked) => setShareWithOrganization(checked === true)}
|
||||
/>
|
||||
{translate(
|
||||
'auto.components.skills.SkillCloudManagementActions.2b8c569ea7',
|
||||
'Current organization'
|
||||
)}
|
||||
</label>
|
||||
{members.length > 0 ? (
|
||||
<div className="max-h-28 space-y-1 overflow-y-auto scrollbar-sleek rounded-md border border-border p-2">
|
||||
{members.map((member) => (
|
||||
<label key={member.userId} className="flex items-center gap-2 text-xs">
|
||||
<Checkbox
|
||||
checked={userIds.includes(member.userId)}
|
||||
disabled={busyAction !== null}
|
||||
onCheckedChange={(checked) =>
|
||||
setUserIds((current) =>
|
||||
checked
|
||||
? [...new Set([...current, member.userId])]
|
||||
: current.filter((id) => id !== member.userId)
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span className="truncate">{member.displayName || member.email}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{unknownUserCount > 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{unknownUserCount}{' '}
|
||||
{translate(
|
||||
'auto.components.skills.SkillCloudManagementActions.3d346ddce8',
|
||||
'existing recipient'
|
||||
)}
|
||||
{unknownUserCount === 1 ? '' : 's'}{' '}
|
||||
{translate(
|
||||
'auto.components.skills.SkillCloudManagementActions.eb603f7888',
|
||||
'outside the current roster will be preserved.'
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={busyAction !== null}
|
||||
onClick={() =>
|
||||
void run('access', () =>
|
||||
window.api.skills.replacePackageAccess({
|
||||
packageId: details.id,
|
||||
userIds,
|
||||
shareWithOrganization
|
||||
})
|
||||
)
|
||||
}
|
||||
>
|
||||
{busyAction === 'access' ? <Loader2 className="size-4 animate-spin" /> : <Save />}
|
||||
{translate(
|
||||
'auto.components.skills.SkillCloudManagementActions.cc8e4ef6ba',
|
||||
'Save access'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-semibold">
|
||||
{translate(
|
||||
@@ -211,8 +107,8 @@ function SkillCloudManagementForm({
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.skills.SkillCloudManagementActions.c7f2b69122',
|
||||
'Unsharing blocks future installs. Copies already installed on any machine remain there.'
|
||||
'auto.components.skills.SkillCloudManagementActions.activeLinkBearerDescription',
|
||||
'Anyone with an active link can inspect and install the skills. Revoking blocks future access but leaves installed copies unchanged.'
|
||||
)}
|
||||
</p>
|
||||
{management.shares.length === 0 ? (
|
||||
@@ -229,8 +125,22 @@ function SkillCloudManagementForm({
|
||||
className="flex items-center gap-2 rounded-md border border-border p-2"
|
||||
>
|
||||
<Badge variant="outline" className="min-w-0 truncate font-mono">
|
||||
{share.id}
|
||||
{share.url ?? share.id}
|
||||
</Badge>
|
||||
{share.url ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={busyAction !== null}
|
||||
onClick={() => void copyShareLink(share.url)}
|
||||
>
|
||||
<Clipboard className="size-4" />
|
||||
{translate(
|
||||
'auto.components.skills.SkillCloudManagementActions.copyLink',
|
||||
'Copy link'
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
size="sm"
|
||||
variant={confirmation === `share:${share.id}` ? 'destructive' : 'outline'}
|
||||
|
||||
@@ -140,6 +140,23 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('SkillInstallDialog', () => {
|
||||
it('describes unavailable bearer links without asking recipients to sign in', async () => {
|
||||
const skills = installApi(vi.fn())
|
||||
skills.resolveShare.mockRejectedValue(new Error('skill_share_not_found'))
|
||||
Object.defineProperty(window, 'api', { configurable: true, value: { skills } })
|
||||
render(<SkillInstallDialog open onOpenChange={() => undefined} />)
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Orca skill link'), {
|
||||
target: { value: 'https://app.orca.dev/skills/share/share_1' }
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Inspect skill' }))
|
||||
|
||||
await screen.findByText(
|
||||
'This share is unavailable. The link may be invalid, expired, or revoked.'
|
||||
)
|
||||
expect(screen.queryByText(/sign in|reconnect/i)).toBeNull()
|
||||
})
|
||||
|
||||
it('installs a selected subset from a shared bundle', async () => {
|
||||
const skills = installApi(vi.fn())
|
||||
skills.resolveShare.mockResolvedValue({
|
||||
|
||||
@@ -87,16 +87,16 @@ export function SkillInstallDialog({
|
||||
const operation = await window.api.skills.resolveShare(shareId)
|
||||
if (operation.status !== 'ok') {
|
||||
setError(
|
||||
operation.status === 'reconnect-required'
|
||||
? 'Reconnect your Orca account to inspect this skill.'
|
||||
: operation.message
|
||||
operation.status === 'unconfigured'
|
||||
? operation.message
|
||||
: 'This share is unavailable. The link may be invalid, expired, or revoked.'
|
||||
)
|
||||
return
|
||||
}
|
||||
setPreview({ shareId, version: operation.value.version })
|
||||
} catch (cause) {
|
||||
console.warn('[skills] share resolution failed:', cause)
|
||||
setError('This share is unavailable or your account does not have access.')
|
||||
setError('This share is unavailable. The link may be invalid, expired, or revoked.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
|
||||
@@ -112,7 +112,6 @@ function skillsApi(
|
||||
placements: []
|
||||
}
|
||||
}),
|
||||
replacePackageAccess: vi.fn().mockResolvedValue({ status: 'ok', value: undefined }),
|
||||
revokeShare: vi.fn().mockResolvedValue({ status: 'ok', value: undefined }),
|
||||
deletePackageVersion: vi.fn().mockResolvedValue({ status: 'ok', value: undefined }),
|
||||
deletePackage: vi.fn().mockResolvedValue({ status: 'ok', value: undefined }),
|
||||
@@ -264,8 +263,6 @@ describe('SkillInstallManagementDialog', () => {
|
||||
const details: SkillCloudPackageDetails = {
|
||||
...packageDetails(versions),
|
||||
management: {
|
||||
userIds: [],
|
||||
shareWithOrganization: false,
|
||||
shares: []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,8 +13,7 @@ import type { DiscoveredSkill } from '../../../../shared/skills'
|
||||
import {
|
||||
SkillShareDialogHeader,
|
||||
SkillSharePreparationReview,
|
||||
SkillSharePublishedLink,
|
||||
type SelectableOrgMember
|
||||
SkillSharePublishedLink
|
||||
} from './SkillShareReviewContent'
|
||||
import { matchingManagedSkillInstall } from './skill-share-package-selection'
|
||||
|
||||
@@ -45,11 +44,7 @@ export function SkillShareDialog({
|
||||
}: SkillShareDialogProps): React.JSX.Element {
|
||||
const selectedSkills = useMemo(() => skills ?? (skill ? [skill] : []), [skill, skills])
|
||||
const [preview, setPreview] = useState<SkillSharePreview | null>(null)
|
||||
const [members, setMembers] = useState<SelectableOrgMember[]>([])
|
||||
const [author, setAuthor] = useState('')
|
||||
const [organization, setOrganization] = useState('')
|
||||
const [audience, setAudience] = useState<'organization' | 'people'>('organization')
|
||||
const [selectedUserIds, setSelectedUserIds] = useState<string[]>([])
|
||||
const [releaseNotes, setReleaseNotes] = useState('')
|
||||
const [progress, setProgress] = useState<SkillShareProgress | null>(null)
|
||||
const [shareUrl, setShareUrl] = useState<string | null>(null)
|
||||
@@ -104,21 +99,6 @@ export function SkillShareDialog({
|
||||
setPublishingNewVersion(managedInstall !== null)
|
||||
const cloud = auth.cloud
|
||||
setAuthor(cloud?.displayName || cloud?.email || '')
|
||||
setOrganization(cloud?.activeOrgName || '')
|
||||
if (!cloud?.activeOrgId) {
|
||||
setAudience('people')
|
||||
setMembers([])
|
||||
return
|
||||
}
|
||||
const result = await window.api.orcaProfiles.orgMembersList({ orgId: cloud.activeOrgId })
|
||||
if (generation.current === current && result.status === 'ok') {
|
||||
setMembers(
|
||||
result.roster.members.filter(
|
||||
(member): member is SelectableOrgMember =>
|
||||
Boolean(member.userId) && member.userId !== cloud.userId
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
.catch((cause) => {
|
||||
console.warn('[skills] share preparation failed:', cause)
|
||||
@@ -168,24 +148,13 @@ export function SkillShareDialog({
|
||||
if (!preview) {
|
||||
return
|
||||
}
|
||||
if (audience === 'people' && selectedUserIds.length === 0) {
|
||||
setError(
|
||||
translate(
|
||||
'auto.components.skills.SkillShareDialog.peopleRequired',
|
||||
'Select at least one teammate.'
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
setPublishing(true)
|
||||
cancellationRequested.current = false
|
||||
setError(null)
|
||||
try {
|
||||
const result = await window.api.skills.publishShare({
|
||||
preparationId: preview.preparationId,
|
||||
releaseNotes,
|
||||
userIds: audience === 'people' ? selectedUserIds : [],
|
||||
shareWithOrganization: audience === 'organization'
|
||||
releaseNotes
|
||||
})
|
||||
if (result.status !== 'ok') {
|
||||
setError(operationError(result.status))
|
||||
@@ -252,12 +221,6 @@ export function SkillShareDialog({
|
||||
<SkillSharePreparationReview
|
||||
preview={preview}
|
||||
author={author}
|
||||
organization={organization}
|
||||
audience={audience}
|
||||
onAudienceChange={setAudience}
|
||||
members={members}
|
||||
selectedUserIds={selectedUserIds}
|
||||
onSelectedUserIdsChange={setSelectedUserIds}
|
||||
releaseNotes={releaseNotes}
|
||||
onReleaseNotesChange={setReleaseNotes}
|
||||
publishing={publishing}
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
import { Check, Clipboard, FileCode2 } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { OrcaOrgMember } from '../../../../shared/orca-profiles'
|
||||
import type {
|
||||
SkillSharePreview,
|
||||
SkillShareProgress
|
||||
} from '../../../../shared/skill-sharing-contract'
|
||||
|
||||
export type SelectableOrgMember = OrcaOrgMember & { userId: string }
|
||||
|
||||
export function SkillShareDialogHeader({
|
||||
published,
|
||||
publishingNewVersion
|
||||
@@ -37,8 +32,8 @@ export function SkillShareDialogHeader({
|
||||
<DialogDescription>
|
||||
{published
|
||||
? translate(
|
||||
'auto.components.skills.SkillShareDialog.readyDescription',
|
||||
'Recipients authenticate with Orca before they can inspect or install it.'
|
||||
'auto.components.skills.SkillShareDialog.readyDescriptionV2',
|
||||
'Anyone with this unlisted link can inspect and install the skills.'
|
||||
)
|
||||
: publishingNewVersion
|
||||
? translate(
|
||||
@@ -46,8 +41,8 @@ export function SkillShareDialogHeader({
|
||||
'Review the exact files, then publish an immutable version to the existing Cloud package.'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.skills.SkillShareDialog.description',
|
||||
'Review the exact files, choose who can access them, then publish an immutable version.'
|
||||
'auto.components.skills.SkillShareDialog.descriptionV2',
|
||||
'Review the exact files, then publish an immutable version behind an unlisted link.'
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
@@ -67,12 +62,6 @@ function byteLabel(bytes: number): string {
|
||||
export function SkillSharePreparationReview({
|
||||
preview,
|
||||
author,
|
||||
organization,
|
||||
audience,
|
||||
onAudienceChange,
|
||||
members,
|
||||
selectedUserIds,
|
||||
onSelectedUserIdsChange,
|
||||
releaseNotes,
|
||||
onReleaseNotesChange,
|
||||
publishing,
|
||||
@@ -81,12 +70,6 @@ export function SkillSharePreparationReview({
|
||||
}: {
|
||||
preview: SkillSharePreview
|
||||
author: string
|
||||
organization: string
|
||||
audience: 'organization' | 'people'
|
||||
onAudienceChange: (audience: 'organization' | 'people') => void
|
||||
members: SelectableOrgMember[]
|
||||
selectedUserIds: string[]
|
||||
onSelectedUserIdsChange: (userIds: string[]) => void
|
||||
releaseNotes: string
|
||||
onReleaseNotesChange: (notes: string) => void
|
||||
publishing: boolean
|
||||
@@ -164,14 +147,17 @@ export function SkillSharePreparationReview({
|
||||
<section className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<Label>
|
||||
{translate('auto.components.skills.SkillShareReviewContent.78d863235c', 'Access')}
|
||||
{translate(
|
||||
'auto.components.skills.SkillShareReviewContent.unlistedLinkTitle',
|
||||
'Unlisted link'
|
||||
)}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{author
|
||||
? translate(
|
||||
'auto.components.skills.SkillShareReviewContent.266527d295',
|
||||
'Publishing as {{value0}}{{value1}}.',
|
||||
{ value0: author, value1: organization ? ` in ${organization}` : '' }
|
||||
'auto.components.skills.SkillShareReviewContent.unlistedPublishingAs',
|
||||
'Publishing as {{value0}}. Anyone with the link can inspect and install these skills.',
|
||||
{ value0: author }
|
||||
)
|
||||
: translate(
|
||||
'auto.components.skills.SkillShareReviewContent.c15d90c10b',
|
||||
@@ -179,81 +165,12 @@ export function SkillSharePreparationReview({
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Tabs
|
||||
value={audience}
|
||||
onValueChange={(value) => onAudienceChange(value as typeof audience)}
|
||||
>
|
||||
<TabsList
|
||||
aria-label={translate(
|
||||
'auto.components.skills.SkillShareReviewContent.6817a7f6f8',
|
||||
'Skill access'
|
||||
)}
|
||||
>
|
||||
<TabsTrigger value="organization" disabled={!organization}>
|
||||
{translate(
|
||||
'auto.components.skills.SkillShareReviewContent.0a49d901ab',
|
||||
'Organization'
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="people">
|
||||
{translate(
|
||||
'auto.components.skills.SkillShareReviewContent.f25429e266',
|
||||
'Selected people'
|
||||
)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="organization" className="pt-2 text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.skills.SkillShareReviewContent.0cd4b3e396',
|
||||
'Everyone currently in'
|
||||
)}{' '}
|
||||
{organization ||
|
||||
translate(
|
||||
'auto.components.skills.SkillShareReviewContent.fe204e06f0',
|
||||
'the organization'
|
||||
)}{' '}
|
||||
{translate(
|
||||
'auto.components.skills.SkillShareReviewContent.8188f5d765',
|
||||
'can access the link.'
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="people" className="space-y-2 pt-2">
|
||||
{members.length > 0 ? (
|
||||
<div className="max-h-32 space-y-1 overflow-y-auto scrollbar-sleek rounded-md border border-border p-2">
|
||||
{members.map((member) => {
|
||||
const checked = selectedUserIds.includes(member.userId)
|
||||
return (
|
||||
<label
|
||||
key={member.userId}
|
||||
className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 hover:bg-accent"
|
||||
>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(next) =>
|
||||
onSelectedUserIdsChange(
|
||||
next
|
||||
? [...new Set([...selectedUserIds, member.userId])]
|
||||
: selectedUserIds.filter((id) => id !== member.userId)
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span className="min-w-0 truncate text-xs">
|
||||
{member.displayName || member.email}
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.skills.SkillShareReviewContent.4895d3e0ee',
|
||||
'No teammates are available.'
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.skills.SkillShareReviewContent.unlistedLinkDetails',
|
||||
'The link is not searchable or listed publicly. Revoke it to block future access; installed copies remain.'
|
||||
)}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
|
||||
@@ -79,7 +79,7 @@ describe('settings navigation metadata', () => {
|
||||
expect(sections.find((section) => section.id === 'mobile')?.group).toBe('setup')
|
||||
})
|
||||
|
||||
it('places Automations and Artifacts first under Workflows', () => {
|
||||
it('places Automations, Artifacts, and Share Skills first under Workflows', () => {
|
||||
const sections = buildSettingsNavigationMetadata({
|
||||
isMac: false,
|
||||
isWindows: false,
|
||||
@@ -88,6 +88,7 @@ describe('settings navigation metadata', () => {
|
||||
})
|
||||
const automations = sections.find((section) => section.id === 'automations')
|
||||
const artifacts = sections.find((section) => section.id === 'artifacts')
|
||||
const shareSkills = sections.find((section) => section.id === 'share-skills')
|
||||
const workflowIds = sections
|
||||
.filter((section) => section.group === 'workflows')
|
||||
.map((section) => section.id)
|
||||
@@ -99,7 +100,9 @@ describe('settings navigation metadata', () => {
|
||||
expect(artifacts?.description).toBe(
|
||||
'Share HTML and Markdown files with your team and manage their public links.'
|
||||
)
|
||||
expect(workflowIds.slice(0, 2)).toEqual(['automations', 'artifacts'])
|
||||
expect(shareSkills).toMatchObject({ group: 'workflows', badge: 'Beta' })
|
||||
expect(shareSkills?.searchEntries[0]?.title).toBe('Unlisted skill links')
|
||||
expect(workflowIds.slice(0, 3)).toEqual(['automations', 'artifacts', 'share-skills'])
|
||||
})
|
||||
|
||||
it('places the Orca account in Set Up on desktop only', () => {
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
SquareTerminal,
|
||||
TextCursorInput,
|
||||
Files,
|
||||
Share2,
|
||||
UserCog,
|
||||
Wrench
|
||||
} from 'lucide-react'
|
||||
@@ -60,6 +61,7 @@ import { getBrowserPaneCombinedSearchEntries } from '@/components/settings/brows
|
||||
import { getNotificationsPaneSearchEntries } from '@/components/settings/notifications-search'
|
||||
import { getOrchestrationPaneSearchEntries } from '@/components/settings/orchestration-search'
|
||||
import { getArtifactsSettingsSearchEntries } from '@/components/settings/artifacts-settings-search'
|
||||
import { getShareSkillsSettingsSearchEntries } from '@/components/settings/share-skills-settings-search'
|
||||
import { getAutomationsSettingsSearchEntries } from '@/components/settings/automations-settings-search'
|
||||
import { getOrcaAccountSettingsSearchEntries } from '@/components/settings/orca-account-settings-search'
|
||||
import { getLinearAgentSkillPaneSearchEntries } from '@/components/settings/linear-agent-skill-search'
|
||||
@@ -348,6 +350,18 @@ export function buildSettingsNavigationMetadata({
|
||||
group: 'workflows',
|
||||
badge: translate('auto.hooks.useSettingsNavigationMetadata.40d80bad8a', 'Beta')
|
||||
},
|
||||
{
|
||||
id: 'share-skills',
|
||||
title: translate('auto.hooks.useSettingsNavigationMetadata.shareSkillsTitle', 'Share Skills'),
|
||||
description: translate(
|
||||
'auto.hooks.useSettingsNavigationMetadata.shareSkillsDescription',
|
||||
'Share one or many skills through unlisted links and manage access by revoking links.'
|
||||
),
|
||||
icon: Share2,
|
||||
searchEntries: getShareSkillsSettingsSearchEntries(),
|
||||
group: 'workflows',
|
||||
badge: translate('auto.hooks.useSettingsNavigationMetadata.40d80bad8a', 'Beta')
|
||||
},
|
||||
{
|
||||
id: 'git',
|
||||
title: translate(
|
||||
|
||||
@@ -901,7 +901,9 @@
|
||||
"artifactsTitle": "Artifacts",
|
||||
"artifactsDescription": "Share HTML and Markdown files with your team and manage their public links.",
|
||||
"automationsTitle": "Automations",
|
||||
"automationsDescription": "Schedule agent work and choose whether Automations appears in the sidebar."
|
||||
"automationsDescription": "Schedule agent work and choose whether Automations appears in the sidebar.",
|
||||
"shareSkillsTitle": "Share Skills",
|
||||
"shareSkillsDescription": "Share one or many skills through unlisted links and manage access by revoking links."
|
||||
},
|
||||
"useAppMenuPaste": {
|
||||
"pasteTooLarge": "Paste is too large."
|
||||
@@ -4144,7 +4146,9 @@
|
||||
"3a51d0f34f": "Cancel upload",
|
||||
"e9d652ae3d": "Cancelling…",
|
||||
"30985d4fc0": "Cancel",
|
||||
"3af85f6add": "Done"
|
||||
"3af85f6add": "Done",
|
||||
"readyDescriptionV2": "Anyone with this unlisted link can inspect and install the skills.",
|
||||
"descriptionV2": "Review the exact files, then publish an immutable version behind an unlisted link."
|
||||
},
|
||||
"SkillCard": {
|
||||
"d25a1b8ae6": "Share skill",
|
||||
@@ -4165,7 +4169,10 @@
|
||||
"3d346ddce8": "existing recipient",
|
||||
"2b8c569ea7": "Current organization",
|
||||
"2552c12fea": "Access",
|
||||
"505cd6105c": "Cloud sharing controls"
|
||||
"505cd6105c": "Cloud sharing controls",
|
||||
"linkCopied": "Share link copied",
|
||||
"activeLinkBearerDescription": "Anyone with an active link can inspect and install the skills. Revoking blocks future access but leaves installed copies unchanged.",
|
||||
"copyLink": "Copy link"
|
||||
},
|
||||
"SkillInstallDialog": {
|
||||
"39acb9e8f4": "Install skill",
|
||||
@@ -4267,7 +4274,10 @@
|
||||
"2dca0b720b": "Publish new skill version",
|
||||
"01c5a17e01": "{{value0}} skills",
|
||||
"01c5a17e02": "Review included skills",
|
||||
"01c5a17e03": "{{value0}} files · {{value1}}"
|
||||
"01c5a17e03": "{{value0}} files · {{value1}}",
|
||||
"unlistedLinkTitle": "Unlisted link",
|
||||
"unlistedPublishingAs": "Publishing as {{value0}}. Anyone with the link can inspect and install these skills.",
|
||||
"unlistedLinkDetails": "The link is not searchable or listed publicly. Revoke it to block future access; installed copies remain."
|
||||
},
|
||||
"SkillBundleInstallFlow": {
|
||||
"01c5a13e01": "Close",
|
||||
@@ -10785,6 +10795,43 @@
|
||||
"on": "On",
|
||||
"auto": "Agent",
|
||||
"off": "Off"
|
||||
},
|
||||
"shareSkills": {
|
||||
"title": "Share Skills",
|
||||
"description": "Share one or many skills through unlisted, revocable links.",
|
||||
"selectTitle": "Select one or more skills",
|
||||
"selectDescription": "Open Skills, choose Share skills, and select the skills to bundle behind one link.",
|
||||
"reviewTitle": "Review and publish",
|
||||
"reviewDescription": "Review included files, scripts, and executables before uploading the immutable bundle.",
|
||||
"copyTitle": "Copy the unlisted link",
|
||||
"copyDescription": "Anyone with the link can inspect and install all or selected skills without signing in.",
|
||||
"manageTitle": "Manage or revoke links",
|
||||
"manageDescription": "Revoking blocks future access. Skills already installed on another machine remain there.",
|
||||
"linkTitle": "Unlisted skill links",
|
||||
"linkDescription": "Shared bundles are not searchable or listed in Orca. The link is the credential, so send it only to people you trust.",
|
||||
"signInTitle": "Sign in to share skills",
|
||||
"signInWebDescription": "Publishing and link management are available in the Orca desktop app.",
|
||||
"signInDescription": "Use your Orca account to publish bundles and manage their links. Recipients do not need an account.",
|
||||
"signingIn": "Signing in…",
|
||||
"signInAgain": "Sign in again",
|
||||
"signIn": "Sign in to Orca",
|
||||
"howToTitle": "How to share skills",
|
||||
"howToDescription": "Publish one skill or a bundle such as a collection of 30 skills behind one link.",
|
||||
"openSkills": "Open Skills",
|
||||
"openSkillsDescription": "Publish a bundle, install from a link, or manage installed and shared skills.",
|
||||
"searchDescription": "Share one or more skills behind an unlisted, revocable link.",
|
||||
"linksReconnect": "Sign in again to manage shared links.",
|
||||
"linksUnavailable": "Shared links are unavailable right now.",
|
||||
"linkCopied": "Share link copied",
|
||||
"linkRevoked": "Link revoked",
|
||||
"revokeFailed": "Orca could not revoke this link.",
|
||||
"activeLinks": "Active shared links",
|
||||
"activeLinksDescription": "Only people with a link can open it. Unshare a link to block future inspection and installs.",
|
||||
"refreshLinks": "Refresh",
|
||||
"noActiveLinks": "No active links. Publish a skill bundle from Skills to create one.",
|
||||
"copyLink": "Copy link",
|
||||
"confirmUnshare": "Confirm unshare",
|
||||
"unshare": "Unshare"
|
||||
}
|
||||
},
|
||||
"right": {
|
||||
|
||||
@@ -39,6 +39,7 @@ const SETTINGS_NAV_TARGETS = [
|
||||
'agents',
|
||||
'orchestration',
|
||||
'artifacts',
|
||||
'share-skills',
|
||||
'automations',
|
||||
'orca-account',
|
||||
'linear',
|
||||
|
||||
@@ -3087,6 +3087,8 @@ function createSkillsApi(): NonNullable<Partial<PreloadApi>['skills']> {
|
||||
Promise.reject(new Error('Skill installation requires the desktop app.')),
|
||||
installPackageVersion: () =>
|
||||
Promise.reject(new Error('Skill installation requires the desktop app.')),
|
||||
installBundlePackageVersion: () =>
|
||||
Promise.reject(new Error('Skill installation requires the desktop app.')),
|
||||
cancelInstall: () => Promise.resolve({ cancelled: false }),
|
||||
previewInstall: () => Promise.reject(new Error('Skill installation requires the desktop app.')),
|
||||
previewBundleInstall: () =>
|
||||
@@ -3095,7 +3097,7 @@ function createSkillsApi(): NonNullable<Partial<PreloadApi>['skills']> {
|
||||
listManagedInstalls: () =>
|
||||
Promise.reject(new Error('Skill installation requires the desktop app.')),
|
||||
getPackage: () => Promise.reject(new Error('Skill installation requires the desktop app.')),
|
||||
replacePackageAccess: () =>
|
||||
listOwnedShares: () =>
|
||||
Promise.reject(new Error('Skill package management requires the desktop app.')),
|
||||
revokeShare: () =>
|
||||
Promise.reject(new Error('Skill package management requires the desktop app.')),
|
||||
|
||||
@@ -32,6 +32,14 @@ export type SkillCloudShare = {
|
||||
url: string
|
||||
}
|
||||
|
||||
export type SkillCloudOwnedShare = SkillCloudShare & {
|
||||
packageId: string
|
||||
name: string
|
||||
description: string
|
||||
createdAt: string
|
||||
expiresAt?: string
|
||||
}
|
||||
|
||||
export type SkillCloudDownloadGrant = {
|
||||
grant: { url: string; expiresAt: string }
|
||||
version: SkillCloudVersion
|
||||
@@ -45,10 +53,9 @@ export type SkillCloudPackageDetails = {
|
||||
canManage: boolean
|
||||
versions: SkillCloudVersion[]
|
||||
management?: {
|
||||
userIds: string[]
|
||||
shareWithOrganization: boolean
|
||||
shares: {
|
||||
id: string
|
||||
url?: string
|
||||
pinnedVersionId?: string
|
||||
createdAt: string
|
||||
expiresAt?: string
|
||||
@@ -63,8 +70,6 @@ export type SkillCloudPublishRequest = SkillCloudOptions & {
|
||||
packageId: string
|
||||
releaseNotes: string
|
||||
pinnedVersionId?: string
|
||||
userIds: string[]
|
||||
shareWithOrganization: boolean
|
||||
onProgress?: (progress: {
|
||||
phase: 'uploading' | 'finalizing'
|
||||
bytesSent: number
|
||||
|
||||
@@ -47,8 +47,6 @@ export type SkillSharePreview = {
|
||||
export type SkillSharePublishInput = {
|
||||
preparationId: string
|
||||
releaseNotes: string
|
||||
userIds: string[]
|
||||
shareWithOrganization: boolean
|
||||
}
|
||||
|
||||
export type SkillSharePublishOperation = SkillCloudOperation<SkillCloudPublishResult>
|
||||
|
||||
Reference in New Issue
Block a user