mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 16:03:26 +00:00
feat(skills): publish managed bundle versions
This commit is contained in:
@@ -879,8 +879,9 @@ does not mean the surrounding phase is complete.
|
||||
- [ ] Add installed-bundle update, rollback, install-on-another-machine, inspect, and remove actions.
|
||||
Grouped inspection, selective update/rollback, and one-surface safe removal are implemented;
|
||||
install-on-another-machine remains.
|
||||
- [ ] Add shared-bundle copy-link, access, revoke, publish-version, and Cloud-delete actions. Access,
|
||||
revoke, version/package deletion are bundle-compatible; copy-link and publish-version remain.
|
||||
- [x] Add shared-bundle copy-link, access, revoke, publish-version, and Cloud-delete actions. Exact
|
||||
full-bundle version publishing reuses its package; ambiguous or partial managed-bundle
|
||||
selections create a new package rather than mutating the wrong history.
|
||||
- [x] Protect locally modified skills during bundle update and removal, including when only part of
|
||||
a bundle remains managed. Keep-local is the default and destructive replacement is explicit.
|
||||
|
||||
|
||||
@@ -41,6 +41,15 @@ const preview: SkillSharePreview = {
|
||||
expiresAt: '2026-08-11T01:00:00.000Z'
|
||||
}
|
||||
|
||||
const secondSkill: DiscoveredSkill = {
|
||||
...skill,
|
||||
id: 'home:second-skill',
|
||||
name: 'second-skill',
|
||||
description: 'Second skill',
|
||||
directoryPath: '/home/skills/second-skill',
|
||||
skillFilePath: '/home/skills/second-skill/SKILL.md'
|
||||
}
|
||||
|
||||
function managedInstall(destinationIdentity: string): ManagedSkillInstall {
|
||||
return {
|
||||
name: 'private-skill',
|
||||
@@ -55,7 +64,11 @@ function managedInstall(destinationIdentity: string): ManagedSkillInstall {
|
||||
}
|
||||
}
|
||||
|
||||
function setup(installs: ManagedSkillInstall[], organization = false) {
|
||||
function setup(
|
||||
installs: ManagedSkillInstall[],
|
||||
organization = false,
|
||||
selectedSkills: DiscoveredSkill[] = [skill]
|
||||
) {
|
||||
let progressListener: ((progress: SkillShareProgress) => void) | null = null
|
||||
const skills = {
|
||||
listManagedInstalls: vi.fn().mockResolvedValue({ status: 'ok', value: installs }),
|
||||
@@ -86,7 +99,7 @@ function setup(installs: ManagedSkillInstall[], organization = false) {
|
||||
}
|
||||
}
|
||||
})
|
||||
render(<SkillShareDialog skill={skill} open onOpenChange={() => undefined} />)
|
||||
render(<SkillShareDialog skills={selectedSkills} open onOpenChange={() => undefined} />)
|
||||
return {
|
||||
skills,
|
||||
emitProgress: (progress: SkillShareProgress) => progressListener?.(progress)
|
||||
@@ -127,6 +140,26 @@ describe('SkillShareDialog', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('publishes a new immutable version for one exact managed bundle', async () => {
|
||||
const installs = [
|
||||
{ ...managedInstall('global:local'), bundleDigest: 'd'.repeat(64) },
|
||||
{
|
||||
...managedInstall('global:local'),
|
||||
name: secondSkill.name,
|
||||
bundleDigest: 'd'.repeat(64)
|
||||
}
|
||||
]
|
||||
const { skills } = setup(installs, false, [skill, secondSkill])
|
||||
|
||||
await screen.findByRole('heading', { name: 'Publish new skill bundle version' })
|
||||
expect(screen.getByRole('button', { name: 'Publish new version' })).toBeTruthy()
|
||||
expect(skills.prepareShare).toHaveBeenCalledWith({
|
||||
skillIds: [skill.id, secondSkill.id],
|
||||
bundleName: 'shared-skills',
|
||||
packageId: 'pkg_1'
|
||||
})
|
||||
})
|
||||
|
||||
it('shows bounded upload progress and supports cancellation', async () => {
|
||||
let rejectPublish: (error: Error) => void = () => undefined
|
||||
const { skills, emitProgress } = setup([], true)
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
SkillSharePreparationReview,
|
||||
SkillSharePublishedLink
|
||||
} from './SkillShareReviewContent'
|
||||
import { matchingManagedSkillInstall } from './skill-share-package-selection'
|
||||
import { matchingManagedSkillShareInstall } from './skill-share-package-selection'
|
||||
|
||||
type SkillShareDialogProps = {
|
||||
skills?: DiscoveredSkill[]
|
||||
@@ -72,10 +72,7 @@ export function SkillShareDialog({
|
||||
try {
|
||||
const operation = await window.api.skills.listManagedInstalls()
|
||||
if (operation.status === 'ok') {
|
||||
managedInstall =
|
||||
selectedSkills.length === 1
|
||||
? matchingManagedSkillInstall(selectedSkills[0], operation.value)
|
||||
: null
|
||||
managedInstall = matchingManagedSkillShareInstall(selectedSkills, operation.value)
|
||||
}
|
||||
} catch (cause) {
|
||||
console.warn('[skills] managed install lookup failed during share:', cause)
|
||||
@@ -210,6 +207,7 @@ export function SkillShareDialog({
|
||||
<SkillShareDialogHeader
|
||||
published={Boolean(shareUrl)}
|
||||
publishingNewVersion={publishingNewVersion}
|
||||
skillCount={selectedSkills.length}
|
||||
/>
|
||||
|
||||
{preparing ? (
|
||||
@@ -265,10 +263,15 @@ export function SkillShareDialog({
|
||||
'auto.components.skills.SkillShareDialog.7aa4ba0dba',
|
||||
'Publish new version'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.skills.SkillShareDialog.0f07fa2a79',
|
||||
'Publish skill'
|
||||
)}
|
||||
: selectedSkills.length > 1
|
||||
? translate(
|
||||
'auto.components.skills.SkillShareDialog.publishBundle',
|
||||
'Publish bundle'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.skills.SkillShareDialog.0f07fa2a79',
|
||||
'Publish skill'
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
) : null}
|
||||
|
||||
@@ -12,22 +12,40 @@ import type {
|
||||
|
||||
export function SkillShareDialogHeader({
|
||||
published,
|
||||
publishingNewVersion
|
||||
publishingNewVersion,
|
||||
skillCount
|
||||
}: {
|
||||
published: boolean
|
||||
publishingNewVersion: boolean
|
||||
skillCount: number
|
||||
}): React.JSX.Element {
|
||||
const bundle = skillCount > 1
|
||||
return (
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{published
|
||||
? translate('auto.components.skills.SkillShareDialog.ready', 'Skill link ready')
|
||||
: publishingNewVersion
|
||||
? bundle
|
||||
? translate(
|
||||
'auto.components.skills.SkillShareReviewContent.2dca0b720b',
|
||||
'Publish new skill version'
|
||||
'auto.components.skills.SkillShareReviewContent.bundleReady',
|
||||
'Skill bundle link ready'
|
||||
)
|
||||
: translate('auto.components.skills.SkillShareDialog.title', 'Share skill')}
|
||||
: translate('auto.components.skills.SkillShareDialog.ready', 'Skill link ready')
|
||||
: publishingNewVersion
|
||||
? bundle
|
||||
? translate(
|
||||
'auto.components.skills.SkillShareReviewContent.publishBundleVersion',
|
||||
'Publish new skill bundle version'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.skills.SkillShareReviewContent.2dca0b720b',
|
||||
'Publish new skill version'
|
||||
)
|
||||
: bundle
|
||||
? translate(
|
||||
'auto.components.skills.SkillShareReviewContent.shareBundle',
|
||||
'Share skill bundle'
|
||||
)
|
||||
: translate('auto.components.skills.SkillShareDialog.title', 'Share skill')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{published
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ManagedSkillInstall } from '../../../../shared/skill-install-contract'
|
||||
import type { DiscoveredSkill } from '../../../../shared/skills'
|
||||
import { matchingManagedSkillShareInstall } from './skill-share-package-selection'
|
||||
|
||||
function skill(name: string): DiscoveredSkill {
|
||||
return {
|
||||
id: `skill:${name}`,
|
||||
name,
|
||||
description: null,
|
||||
providers: ['agent-skills'],
|
||||
sourceKind: 'home',
|
||||
sourceLabel: 'Home',
|
||||
rootPath: '/skills',
|
||||
directoryPath: `/skills/${name}`,
|
||||
skillFilePath: `/skills/${name}/SKILL.md`,
|
||||
installed: true,
|
||||
fileCount: 1,
|
||||
updatedAt: null
|
||||
}
|
||||
}
|
||||
|
||||
function install(name: string, destinationIdentity = 'global:local'): ManagedSkillInstall {
|
||||
return {
|
||||
name,
|
||||
packageId: 'pkg_1',
|
||||
versionId: 'ver_1',
|
||||
packageDigest: 'a'.repeat(64),
|
||||
bundleDigest: 'b'.repeat(64),
|
||||
scope: 'global',
|
||||
destinationIdentity,
|
||||
destination: { scope: 'global' },
|
||||
installedAt: '2026-08-11T00:00:00.000Z',
|
||||
state: 'unchanged'
|
||||
}
|
||||
}
|
||||
|
||||
describe('matchingManagedSkillShareInstall', () => {
|
||||
it('matches one complete managed bundle and rejects a partial selection', () => {
|
||||
const installs = [install('alpha'), install('beta')]
|
||||
|
||||
expect(matchingManagedSkillShareInstall([skill('alpha'), skill('beta')], installs)).toBe(
|
||||
installs[0]
|
||||
)
|
||||
expect(matchingManagedSkillShareInstall([skill('alpha')], installs)).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects the same bundle installed at multiple destinations', () => {
|
||||
const installs = [
|
||||
install('alpha'),
|
||||
install('beta'),
|
||||
install('alpha', 'global:other'),
|
||||
install('beta', 'global:other')
|
||||
]
|
||||
|
||||
expect(matchingManagedSkillShareInstall([skill('alpha'), skill('beta')], installs)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,18 +1,58 @@
|
||||
import type { ManagedSkillInstall } from '../../../../shared/skill-install-contract'
|
||||
import type { DiscoveredSkill } from '../../../../shared/skills'
|
||||
|
||||
export function matchingManagedSkillInstall(
|
||||
skill: DiscoveredSkill,
|
||||
function skillScope(skill: DiscoveredSkill): ManagedSkillInstall['scope'] | null {
|
||||
return skill.sourceKind === 'home' ? 'global' : skill.sourceKind === 'repo' ? 'workspace' : null
|
||||
}
|
||||
|
||||
export function matchingManagedSkillShareInstall(
|
||||
skills: readonly DiscoveredSkill[],
|
||||
installs: ManagedSkillInstall[]
|
||||
): ManagedSkillInstall | null {
|
||||
const scope =
|
||||
skill.sourceKind === 'home' ? 'global' : skill.sourceKind === 'repo' ? 'workspace' : null
|
||||
if (!scope) {
|
||||
if (skills.length === 0) {
|
||||
return null
|
||||
}
|
||||
const matches = installs.filter(
|
||||
(install) =>
|
||||
install.name === skill.name && install.scope === scope && install.state !== 'missing'
|
||||
if (skills.length === 1) {
|
||||
const scope = skillScope(skills[0])
|
||||
const matches = installs.filter(
|
||||
(install) =>
|
||||
install.name === skills[0].name &&
|
||||
install.scope === scope &&
|
||||
install.state !== 'missing' &&
|
||||
!install.bundleDigest
|
||||
)
|
||||
if (matches.length === 1) {
|
||||
return matches[0]
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
const scope = skillScope(skills[0])
|
||||
if (!scope || skills.some((skill) => skillScope(skill) !== scope)) {
|
||||
return null
|
||||
}
|
||||
const selectedNames = new Set(skills.map((skill) => skill.name))
|
||||
const groups = new Map<string, ManagedSkillInstall[]>()
|
||||
for (const install of installs) {
|
||||
if (install.scope !== scope || install.state === 'missing' || !install.bundleDigest) {
|
||||
continue
|
||||
}
|
||||
const key = [
|
||||
install.packageId,
|
||||
install.versionId,
|
||||
install.destinationIdentity,
|
||||
install.bundleDigest
|
||||
].join('\0')
|
||||
const group = groups.get(key) ?? []
|
||||
group.push(install)
|
||||
groups.set(key, group)
|
||||
}
|
||||
const matches = [...groups.values()].filter(
|
||||
(group) =>
|
||||
group.length === selectedNames.size &&
|
||||
group.every((install) => selectedNames.has(install.name)) &&
|
||||
new Set(group.map((install) => install.name)).size === group.length
|
||||
)
|
||||
return matches.length === 1 ? matches[0] : null
|
||||
return matches.length === 1 ? (matches[0][0] ?? null) : null
|
||||
}
|
||||
|
||||
@@ -4148,7 +4148,8 @@
|
||||
"30985d4fc0": "Cancel",
|
||||
"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."
|
||||
"descriptionV2": "Review the exact files, then publish an immutable version behind an unlisted link.",
|
||||
"publishBundle": "Publish bundle"
|
||||
},
|
||||
"SkillCard": {
|
||||
"d25a1b8ae6": "Share skill",
|
||||
@@ -4277,7 +4278,10 @@
|
||||
"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."
|
||||
"unlistedLinkDetails": "The link is not searchable or listed publicly. Revoke it to block future access; installed copies remain.",
|
||||
"bundleReady": "Skill bundle link ready",
|
||||
"publishBundleVersion": "Publish new skill bundle version",
|
||||
"shareBundle": "Share skill bundle"
|
||||
},
|
||||
"SkillBundleInstallFlow": {
|
||||
"01c5a13e01": "Close",
|
||||
|
||||
Reference in New Issue
Block a user