From 8c1f02c32c4cb344d233b1aee05fa535cd1d572b Mon Sep 17 00:00:00 2001 From: Daniel Petro <45949032+WellDunDun@users.noreply.github.com> Date: Thu, 3 Sep 2026 08:50:36 +0000 Subject: [PATCH] chore: export from selftune-cloud-app --- .changeset/imported-set-license-draft.md | 5 + .changeset/publication-preflight.md | 5 + .github/release-notes.json | 11 +++ .github/scripts/generate-sbom.sh | 70 +++++++++++++ .github/workflows/ci.yml | 22 +++++ .github/workflows/publish.yml | 70 ++----------- apps/desktop/package.json | 2 +- apps/local-dashboard/src/api.ts | 10 +- apps/local/src/dashboard-operations.ts | 68 ++++++++----- apps/local/src/routes/library-transfer.ts | 9 +- apps/local/tests/dashboard-operations.test.ts | 60 +++++++++++ bun.lock | 2 +- package.json | 2 +- packages/dashboard-core/src/host/adapter.ts | 4 +- .../projects/ShareSkillSetDialog.test.tsx | 42 +++++++- .../screens/projects/ShareSkillSetDialog.tsx | 50 +++++----- packages/runtime/license-draft.ts | 28 ++++-- .../runtime/skill-set-license-draft.test.ts | 89 +++++++++++++++++ packages/runtime/skill-set-license-draft.ts | 99 +++++++++++++++++++ skill/SKILL.md | 2 +- tests/scripts/release-workflows.test.ts | 4 +- tests/trust-floor/publish-deps.test.ts | 88 +++++++---------- 22 files changed, 556 insertions(+), 186 deletions(-) create mode 100644 .changeset/imported-set-license-draft.md create mode 100644 .changeset/publication-preflight.md create mode 100644 .github/scripts/generate-sbom.sh create mode 100644 packages/runtime/skill-set-license-draft.test.ts create mode 100644 packages/runtime/skill-set-license-draft.ts diff --git a/.changeset/imported-set-license-draft.md b/.changeset/imported-set-license-draft.md new file mode 100644 index 00000000..65b544ff --- /dev/null +++ b/.changeset/imported-set-license-draft.md @@ -0,0 +1,5 @@ +--- +"@selftune/desktop": patch +--- + +Fix license draft review for imported Skill Sets. Show draft errors in the dialog and save approved license terms as a new Set revision without changing the original package. diff --git a/.changeset/publication-preflight.md b/.changeset/publication-preflight.md new file mode 100644 index 00000000..1c73bc87 --- /dev/null +++ b/.changeset/publication-preflight.md @@ -0,0 +1,5 @@ +--- +"@selftune/desktop": patch +--- + +Check the packed npm candidate and its validated dependency inventory before merge using the same SBOM generator as publication. diff --git a/.github/release-notes.json b/.github/release-notes.json index ab6bd15e..f0bcbab2 100644 --- a/.github/release-notes.json +++ b/.github/release-notes.json @@ -3,6 +3,17 @@ "generatedFrom": "sites/docs/changelog.mdx", "docsUrl": "https://docs.selftune.dev/changelog", "entries": [ + { + "label": "2026-09-03", + "description": "Skill Set license draft recovery", + "tags": ["Dashboard", "OSS"], + "rss": { + "title": null, + "description": null + }, + "bullets": [], + "versionRange": null + }, { "label": "2026-09-02", "description": "Signed macOS runtime startup validation", diff --git a/.github/scripts/generate-sbom.sh b/.github/scripts/generate-sbom.sh new file mode 100644 index 00000000..9fdcbb9e --- /dev/null +++ b/.github/scripts/generate-sbom.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +set -euo pipefail +: "${TARBALL_PATH:?Set TARBALL_PATH to the packed candidate}" +: "${SBOM_TOOLCHAIN_DIR:?Set SBOM_TOOLCHAIN_DIR to the installed locked toolchain}" +: "${SBOM_OUTPUT:?Set SBOM_OUTPUT to an absolute output path}" +TARBALL_PATH="$(cd "$(dirname "$TARBALL_PATH")" && pwd)/$(basename "$TARBALL_PATH")" +SBOM_TOOLCHAIN_DIR="$(cd "$SBOM_TOOLCHAIN_DIR" && pwd)" +SBOM_OUTPUT="$(cd "$(dirname "$SBOM_OUTPUT")" && pwd)/$(basename "$SBOM_OUTPUT")" +SBOM_TMP="$(mktemp -d)" +trap 'rm -rf "$SBOM_TMP"' EXIT +tar -xzf "$TARBALL_PATH" -C "$SBOM_TMP" +cd "$SBOM_TMP/package" +node -e "const fs=require('node:fs'); const pkg=JSON.parse(fs.readFileSync('package.json','utf8')); delete pkg.workspaces; fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');" + +# The SBOM is generated from the packed candidate before publishing to npm so +# an unresolvable bundled dependency tree fails the release while npm, +# the GitHub release, and the self-host image are still in sync. +npm install --package-lock-only --ignore-scripts --omit=dev --no-audit --no-fund >/dev/null + +SBOM_LOG="$SBOM_TMP/cyclonedx.log" +if ! node "$SBOM_TOOLCHAIN_DIR/node_modules/@cyclonedx/cyclonedx-npm/bin/cyclonedx-npm-cli.js" \ + --package-lock-only \ + --omit dev \ + --no-workspaces \ + --validate \ + -v -v \ + --output-file "$SBOM_OUTPUT" \ + >"$SBOM_LOG" 2>&1; then + cat "$SBOM_LOG" >&2 + exit 1 +fi +cat "$SBOM_LOG" +if grep --fixed-strings --quiet "skipped validating BOM" "$SBOM_LOG"; then + echo "CycloneDX skipped SBOM validation; refusing to publish." >&2 + exit 1 +fi +if ! grep --fixed-strings --line-regexp --quiet "INFO | BOM result appears valid" "$SBOM_LOG"; then + echo "CycloneDX did not confirm successful SBOM validation; refusing to publish." >&2 + exit 1 +fi + +node - "$SBOM_TMP/package/package.json" "$SBOM_OUTPUT" <<'NODE' +const fs = require("node:fs"); +const [, , packagePath, bomPath] = process.argv; +const pkg = JSON.parse(fs.readFileSync(packagePath, "utf8")); +const bom = JSON.parse(fs.readFileSync(bomPath, "utf8")); +const rootRef = bom.metadata?.component?.["bom-ref"]; +const root = bom.dependencies?.find((entry) => entry.ref === rootRef); +if (!root) throw new Error(`Missing SBOM root dependency node ${rootRef}`); +const namesByRef = new Map( + (bom.components ?? []).map((component) => [ + component["bom-ref"], + `${component.group ? `${component.group}/` : ""}${component.name}`, + ]), +); +const actual = (root.dependsOn ?? []) + .map((ref) => { + const name = namesByRef.get(ref); + if (!name) throw new Error(`Unresolved SBOM root dependency ${ref}`); + return name; + }) + .sort(); +const expected = Object.keys(pkg.dependencies ?? {}).sort(); +if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error( + `Incomplete SBOM root dependencies: expected ${expected.length} [${expected.join(", ")}], got ${actual.length} [${actual.join(", ")}]`, + ); +} +console.log(`SBOM root dependency coverage: ${actual.length}/${expected.length}`); +NODE diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd14204e..f14e869b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,7 @@ jobs: build-dashboard: needs: [lint] runs-on: ubuntu-latest + timeout-minutes: 20 permissions: contents: read steps: @@ -48,6 +49,27 @@ jobs: run: bunx tsc --noEmit - run: bun run build:dashboard + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + with: + node-version: "24" + - name: Pin npm to the release toolchain + run: npm install --global npm@11 + - name: Pack and smoke the publication candidate + shell: bash + run: | + set -euo pipefail + bun run pack:release >/dev/null + tarball="$(node -p "const pkg=require('./package.json'); pkg.name.replace(/^@/,'').replace(/\//g,'-') + '-' + pkg.version + '.tgz'")" + bun run scripts/smoke-packed-package.ts "$tarball" + echo "TARBALL_PATH=$tarball" >> "$GITHUB_ENV" + - name: Install locked SBOM toolchain + run: npm ci --prefix .github/sbom-toolchain --ignore-scripts --omit=dev --no-audit --no-fund + - name: Generate SBOM + env: + SBOM_TOOLCHAIN_DIR: ${{ github.workspace }}/.github/sbom-toolchain + SBOM_OUTPUT: ${{ github.workspace }}/sbom.cdx.json + run: bash .github/scripts/generate-sbom.sh + test: needs: [lint] runs-on: ubuntu-latest diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ddfe2b4f..8a1ea0bd 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -365,7 +365,9 @@ jobs: with: persist-credentials: false ref: ${{ github.sha }} - sparse-checkout: .github/sbom-toolchain + sparse-checkout: | + .github/sbom-toolchain + .github/scripts path: .release-workflow - name: Install locked SBOM toolchain @@ -381,70 +383,10 @@ jobs: shell: bash env: TARBALL_PATH: ${{ steps.pack.outputs.tarball }} + SBOM_TOOLCHAIN_DIR: ${{ github.workspace }}/.release-workflow/.github/sbom-toolchain + SBOM_OUTPUT: ${{ github.workspace }}/sbom.cdx.json run: | - set -euo pipefail - TMPDIR="$(mktemp -d)" - trap 'rm -rf "$TMPDIR"' EXIT - tar -xzf "$TARBALL_PATH" -C "$TMPDIR" - cd "$TMPDIR/package" - node -e "const fs=require('node:fs'); const pkg=JSON.parse(fs.readFileSync('package.json','utf8')); delete pkg.workspaces; fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');" - - # The SBOM is generated from the packed candidate before publishing to npm so - # an unresolvable bundled dependency tree fails the release while npm, - # the GitHub release, and the self-host image are still in sync. - npm install --package-lock-only --ignore-scripts --omit=dev --no-audit --no-fund >/dev/null - - SBOM_LOG="$TMPDIR/cyclonedx.log" - if ! node "$GITHUB_WORKSPACE/.release-workflow/.github/sbom-toolchain/node_modules/@cyclonedx/cyclonedx-npm/bin/cyclonedx-npm-cli.js" \ - --package-lock-only \ - --omit dev \ - --no-workspaces \ - --validate \ - -v -v \ - --output-file "$GITHUB_WORKSPACE/sbom.cdx.json" \ - >"$SBOM_LOG" 2>&1; then - cat "$SBOM_LOG" >&2 - exit 1 - fi - cat "$SBOM_LOG" - if grep --fixed-strings --quiet "skipped validating BOM" "$SBOM_LOG"; then - echo "CycloneDX skipped SBOM validation; refusing to publish." >&2 - exit 1 - fi - if ! grep --fixed-strings --line-regexp --quiet "INFO | BOM result appears valid" "$SBOM_LOG"; then - echo "CycloneDX did not confirm successful SBOM validation; refusing to publish." >&2 - exit 1 - fi - - node - "$TMPDIR/package/package.json" "$GITHUB_WORKSPACE/sbom.cdx.json" <<'NODE' - const fs = require("node:fs"); - const [, , packagePath, bomPath] = process.argv; - const pkg = JSON.parse(fs.readFileSync(packagePath, "utf8")); - const bom = JSON.parse(fs.readFileSync(bomPath, "utf8")); - const rootRef = bom.metadata?.component?.["bom-ref"]; - const root = bom.dependencies?.find((entry) => entry.ref === rootRef); - if (!root) throw new Error(`Missing SBOM root dependency node ${rootRef}`); - const namesByRef = new Map( - (bom.components ?? []).map((component) => [ - component["bom-ref"], - `${component.group ? `${component.group}/` : ""}${component.name}`, - ]), - ); - const actual = (root.dependsOn ?? []) - .map((ref) => { - const name = namesByRef.get(ref); - if (!name) throw new Error(`Unresolved SBOM root dependency ${ref}`); - return name; - }) - .sort(); - const expected = Object.keys(pkg.dependencies ?? {}).sort(); - if (JSON.stringify(actual) !== JSON.stringify(expected)) { - throw new Error( - `Incomplete SBOM root dependencies: expected ${expected.length} [${expected.join(", ")}], got ${actual.length} [${actual.join(", ")}]`, - ); - } - console.log(`SBOM root dependency coverage: ${actual.length}/${expected.length}`); - NODE + bash "$GITHUB_WORKSPACE/.release-workflow/.github/scripts/generate-sbom.sh" - name: Publish npm candidate after artifact gates shell: bash diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 0e066864..0af95115 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@selftune/desktop", - "version": "0.4.8", + "version": "0.4.9", "private": true, "description": "Native desktop control plane for SelfTune skill observability and improvement", "homepage": "https://selftune.dev", diff --git a/apps/local-dashboard/src/api.ts b/apps/local-dashboard/src/api.ts index 40f2c8c1..b629792a 100644 --- a/apps/local-dashboard/src/api.ts +++ b/apps/local-dashboard/src/api.ts @@ -247,10 +247,16 @@ export interface LicenseDraftPreviewResponse { function licenseDraftRequest( path: "/api/v2/library/license/preview" | "/api/v2/library/license/apply", - input: { skillId: string; terms: LicenseDraftTermsInput; previewId?: string }, + input: { + skillId: string; + skillSetId?: string; + terms: LicenseDraftTermsInput; + previewId?: string; + }, ): Promise { return portfolioRequest(path, { skill_id: input.skillId, + ...(input.skillSetId ? { set_id: input.skillSetId } : {}), ...(input.previewId ? { preview_id: input.previewId } : {}), terms: { copyright_holder: input.terms.copyrightHolder, @@ -262,6 +268,7 @@ function licenseDraftRequest( export function previewLibrarySkillLicense(input: { skillId: string; + skillSetId?: string; terms: LicenseDraftTermsInput; }): Promise { return licenseDraftRequest("/api/v2/library/license/preview", input); @@ -269,6 +276,7 @@ export function previewLibrarySkillLicense(input: { export function applyLibrarySkillLicense(input: { skillId: string; + skillSetId?: string; previewId: string; terms: LicenseDraftTermsInput; }): Promise { diff --git a/apps/local/src/dashboard-operations.ts b/apps/local/src/dashboard-operations.ts index 40d5a7a0..ad266901 100644 --- a/apps/local/src/dashboard-operations.ts +++ b/apps/local/src/dashboard-operations.ts @@ -1,3 +1,7 @@ +import { + previewSkillSetLicenseDraft, + applySkillSetLicenseDraft, +} from "@selftune/runtime/skill-set-license-draft"; import { join, resolve } from "node:path"; import type { Database } from "bun:sqlite"; @@ -667,11 +671,13 @@ export class DashboardOperations extends Context.Service< readonly previewLicenseDraft: ( skillId: string, terms: LicenseDraftTerms, + skillSetId?: string, ) => Effect.Effect; readonly applyLicenseDraft: ( skillId: string, previewId: string, terms: LicenseDraftTerms, + skillSetId?: string, ) => Effect.Effect; readonly remoteLibraryShare: ( action: RemoteLibraryShareAction, @@ -1493,30 +1499,44 @@ export function makeDashboardOperationsLayer(options: DashboardOperationOverride }, ), ), - previewLicenseDraft: (skillId, terms) => - Effect.flatMap(libraryReport.read, (snapshot) => - attempt("library.license.preview", () => { - const skill = snapshot.skills.find((candidate) => candidate.skillId === skillId); - const location = - skill?.locations.find((candidate) => candidate.active) ?? skill?.locations[0]; - if (!location) throw new Error("Refresh the Library and select this skill again."); - return previewLocalLicenseDraft(location.skillPath, terms); - }), - ), - applyLicenseDraft: (skillId, previewId, terms) => - Effect.flatMap(libraryReport.read, (snapshot) => - invalidatingAttempt("library.license.apply", () => { - const skill = snapshot.skills.find((candidate) => candidate.skillId === skillId); - const location = - skill?.locations.find((candidate) => candidate.active) ?? skill?.locations[0]; - if (!location) throw new Error("Refresh the Library and select this skill again."); - return applyLocalLicenseDraft({ - skillPath: location.skillPath, - previewId, - terms, - }); - }), - ), + previewLicenseDraft: (skillId, terms, skillSetId) => + skillSetId !== undefined + ? attempt("library.license.preview", () => + previewSkillSetLicenseDraft(skillSetId, skillId, terms, { + configRoot: options.skillSetConfigRoot ?? SELFTUNE_CONFIG_DIR, + }), + ) + : Effect.flatMap(libraryReport.read, (snapshot) => + attempt("library.license.preview", () => { + const skill = snapshot.skills.find((candidate) => candidate.skillId === skillId); + const location = + skill?.locations.find((candidate) => candidate.active) ?? skill?.locations[0]; + if (!location) + throw new Error("Refresh the Library and select this skill again."); + return previewLocalLicenseDraft(location.packagePath, terms); + }), + ), + applyLicenseDraft: (skillId, previewId, terms, skillSetId) => + skillSetId !== undefined + ? invalidatingAttempt("library.license.apply", () => + applySkillSetLicenseDraft(skillSetId, skillId, previewId, terms, { + configRoot: options.skillSetConfigRoot ?? SELFTUNE_CONFIG_DIR, + }), + ) + : Effect.flatMap(libraryReport.read, (snapshot) => + invalidatingAttempt("library.license.apply", () => { + const skill = snapshot.skills.find((candidate) => candidate.skillId === skillId); + const location = + skill?.locations.find((candidate) => candidate.active) ?? skill?.locations[0]; + if (!location) + throw new Error("Refresh the Library and select this skill again."); + return applyLocalLicenseDraft({ + skillPath: location.packagePath, + previewId, + terms, + }); + }), + ), remoteLibraryShare: (action, input) => attempt(`remote_library.share.${action}`, () => runRemoteLibraryShare(action, input)), workspace: (action, input) => diff --git a/apps/local/src/routes/library-transfer.ts b/apps/local/src/routes/library-transfer.ts index 0131bf8f..6268e324 100644 --- a/apps/local/src/routes/library-transfer.ts +++ b/apps/local/src/routes/library-transfer.ts @@ -41,10 +41,12 @@ const LicenseDraftTermsBody = Schema.Struct({ year: Schema.Number, }); const PreviewLicenseBody = Schema.Struct({ + set_id: Schema.optional(Schema.String), skill_id: Schema.String, terms: LicenseDraftTermsBody, }); const ApplyLicenseBody = Schema.Struct({ + set_id: Schema.optional(Schema.String), skill_id: Schema.String, preview_id: Schema.String, terms: LicenseDraftTermsBody, @@ -141,7 +143,11 @@ export const routeLibraryTransfer = Effect.fn("DashboardApplication.routeLibrary ); return { response: Response.json( - yield* operations.previewLicenseDraft(body.skill_id, licenseTerms(body.terms)), + yield* operations.previewLicenseDraft( + body.skill_id, + licenseTerms(body.terms), + body.set_id, + ), { headers: dashboardCorsHeaders() }, ), installed: false, @@ -160,6 +166,7 @@ export const routeLibraryTransfer = Effect.fn("DashboardApplication.routeLibrary body.skill_id, body.preview_id, licenseTerms(body.terms), + body.set_id, ), { headers: dashboardCorsHeaders() }, ), diff --git a/apps/local/tests/dashboard-operations.test.ts b/apps/local/tests/dashboard-operations.test.ts index 79d85b6b..c090aed4 100644 --- a/apps/local/tests/dashboard-operations.test.ts +++ b/apps/local/tests/dashboard-operations.test.ts @@ -1,3 +1,5 @@ +import { createSkillSet, getSkillSet } from "@selftune/library"; +import { handleDashboardApplicationRoute } from "../src/routes/application.js"; import { describe, expect, test } from "bun:test"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -342,3 +344,61 @@ describe("DashboardOperations", () => { } }); }); + +test("license routes preview and revise a Set with no installed Library location", async () => { + const root = mkdtempSync(join(tmpdir(), "selftune-license-route-")); + const source = join(root, "source"); + mkdirSync(source); + writeFileSync( + join(source, "SKILL.md"), + "---\nname: marketing-social\ndescription: Marketing\n---\n", + ); + const options = { configRoot: join(root, "config") }; + const set = createSkillSet( + { + name: "Ithraa", + harnesses: ["codex"], + skills: [{ name: "marketing-social", package_path: source }], + }, + options, + ); + const runtime = ManagedRuntime.make( + makeDashboardOperationsLayer({ + skillSetConfigRoot: options.configRoot, + libraryLoader: () => { + throw new Error("Installed Library must not be consulted for pinned Set drafts"); + }, + }), + ); + const origin = "http://127.0.0.1:3141"; + const body = { + set_id: set.set_id, + skill_id: "marketing-social", + terms: { copyright_holder: "Daniel Petro", licensed_organization: "Ithraa Center", year: 2026 }, + }; + async function request(action: string, input: unknown, status = 200) { + const req = new Request(`${origin}/api/v2/library/license/${action}`, { + method: "POST", + headers: { Origin: origin, "Content-Type": "application/json" }, + body: JSON.stringify(input), + }); + const result = await runtime.runPromise( + handleDashboardApplicationRoute(req, new URL(req.url), { allowedOrigins: new Set([origin]) }), + ); + expect(result?.status).toBe(status); + if (!result) throw new Error("License route did not handle the request"); + return result.json(); + } + try { + const preview = await request("preview", body); + expect(preview.files).toHaveLength(2); + expect(getSkillSet(set.set_id, options).revision).toBe(1); + await request("apply", { ...body, preview_id: preview.previewId }); + expect(getSkillSet(set.set_id, options).revision).toBe(2); + const blocked = await request("preview", body, 409); + expect(JSON.stringify(blocked)).toContain("already bundles a LICENSE file"); + } finally { + await runtime.dispose(); + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/bun.lock b/bun.lock index 361255b3..5822f4b0 100644 --- a/bun.lock +++ b/bun.lock @@ -82,7 +82,7 @@ }, "apps/desktop": { "name": "@selftune/desktop", - "version": "0.4.8", + "version": "0.4.9", "dependencies": { "@selftune/config": "workspace:*", "@sentry/electron": "^7.8.0", diff --git a/package.json b/package.json index 7131d843..8d35df7c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "selftune", - "version": "0.4.8", + "version": "0.4.9", "description": "Skill-level observability and self-improvement for AI agents — monitors skill routing, detects missed triggers, and evolves descriptions automatically", "keywords": [ "agent", diff --git a/packages/dashboard-core/src/host/adapter.ts b/packages/dashboard-core/src/host/adapter.ts index 5c89e5f1..f97f34e6 100644 --- a/packages/dashboard-core/src/host/adapter.ts +++ b/packages/dashboard-core/src/host/adapter.ts @@ -173,11 +173,11 @@ export interface DashboardLibraryActions { backup?: DashboardLibraryAction; share?: DashboardShareAction; previewLicenseDraft?: DashboardLibraryAction< - { skillId: string; terms: LibraryLicenseDraftTerms }, + { skillId: string; skillSetId?: string; terms: LibraryLicenseDraftTerms }, LibraryLicenseDraftPreviewModel >; applyLicenseDraft?: DashboardLibraryAction< - { skillId: string; previewId: string; terms: LibraryLicenseDraftTerms }, + { skillId: string; skillSetId?: string; previewId: string; terms: LibraryLicenseDraftTerms }, LibraryLicenseDraftPreviewModel >; installTargets?: ReadonlyArray<{ id: LibraryInstallAgent; label: string }>; diff --git a/packages/dashboard-core/src/screens/projects/ShareSkillSetDialog.test.tsx b/packages/dashboard-core/src/screens/projects/ShareSkillSetDialog.test.tsx index eef1754f..7bbbd1e7 100644 --- a/packages/dashboard-core/src/screens/projects/ShareSkillSetDialog.test.tsx +++ b/packages/dashboard-core/src/screens/projects/ShareSkillSetDialog.test.tsx @@ -211,9 +211,49 @@ describe("ShareSkillSetDialog", () => { fireEvent.click(screen.getByRole("button", { name: "Apply reviewed license" })); await waitFor(() => expect(apply).toHaveBeenCalledWith( - expect.objectContaining({ skillId: "reviewer", previewId: "preview-1" }), + expect.objectContaining({ + skillId: "reviewer", + skillSetId: "review-set", + previewId: "preview-1", + }), ), ); expect(screen.getByRole("button", { name: "Create link" })).toBeTruthy(); }); + it("shows draft failures and lets the user retry without leaving the draft", async () => { + const preview = vi.fn(async () => { + throw new Error("This skill already bundles a LICENSE file."); + }); + render( + { + throw new Error("Missing license"); + }), + )} + previewLicenseAction={{ access: "available", execute: preview }} + applyLicenseAction={{ access: "available", execute: vi.fn() }} + open + onOpenChange={() => {}} + />, + ); + fireEvent.click(screen.getByRole("button", { name: "Create link" })); + fireEvent.click(await screen.findByRole("button", { name: "Draft missing license" })); + fireEvent.change(screen.getByLabelText("Copyright holder"), { + target: { value: "Daniel Petro" }, + }); + fireEvent.change(screen.getByLabelText("Licensed organization"), { + target: { value: "Ithraa Center" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Review draft" })); + expect((await screen.findByRole("alert")).textContent).toBe( + "This skill already bundles a LICENSE file.", + ); + expect(preview).toHaveBeenCalledWith( + expect.objectContaining({ skillId: "reviewer", skillSetId: "review-set" }), + ); + fireEvent.click(screen.getByRole("button", { name: "Review draft" })); + await waitFor(() => expect(preview).toHaveBeenCalledTimes(2)); + }); }); diff --git a/packages/dashboard-core/src/screens/projects/ShareSkillSetDialog.tsx b/packages/dashboard-core/src/screens/projects/ShareSkillSetDialog.tsx index 74693d40..510e4dac 100644 --- a/packages/dashboard-core/src/screens/projects/ShareSkillSetDialog.tsx +++ b/packages/dashboard-core/src/screens/projects/ShareSkillSetDialog.tsx @@ -108,7 +108,11 @@ export function ShareSkillSetDialog({ setError(null); try { setLicensePreview( - await previewLicenseAction.execute({ skillId: draftSkillId, terms: terms() }), + await previewLicenseAction.execute({ + skillId: draftSkillId, + skillSetId: skillSet.id, + terms: terms(), + }), ); } catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); @@ -124,6 +128,7 @@ export function ShareSkillSetDialog({ try { await applyLicenseAction.execute({ skillId: draftSkillId, + skillSetId: skillSet.id, previewId: licensePreview.previewId, terms: terms(), }); @@ -391,29 +396,30 @@ export function ShareSkillSetDialog({ Every included skill must have distributable license evidence. Usage telemetry is off unless the recipient separately opts in.

- {error ? ( -
-

- {error} -

- {previewLicenseAction?.access === "available" && - applyLicenseAction?.access === "available" && - /license/i.test(error) ? ( - - ) : null} -
- ) : null} )} + {error ? ( +
+

+ {error} +

+ {!draftingLicense && + previewLicenseAction?.access === "available" && + applyLicenseAction?.access === "available" && + /license/i.test(error) ? ( + + ) : null} +
+ ) : null} {draftingLicense ? (