diff --git a/convex/lib/skillPublish.test.ts b/convex/lib/skillPublish.test.ts index 52588961..06211f55 100644 --- a/convex/lib/skillPublish.test.ts +++ b/convex/lib/skillPublish.test.ts @@ -147,6 +147,10 @@ description: Automation workflow for recurring reports. skillId: "skills:demo", versionId: "skillVersions:demo", embeddingId: "skillEmbeddings:demo", + status: "published", + slug: "automation-helper", + version: "1.0.0", + publicationStatus: "published", }); expect(runMutation).toHaveBeenCalledWith( expect.anything(), diff --git a/convex/lib/skillPublish.ts b/convex/lib/skillPublish.ts index 1cadfd98..53355a13 100644 --- a/convex/lib/skillPublish.ts +++ b/convex/lib/skillPublish.ts @@ -510,7 +510,13 @@ async function publishVersionForUserInternal( skillInsertArgs, )) as PublishResult; await scheduleSkillPublishFollowups(ctx, publishResult, followup); - return publishResult; + return { + ...publishResult, + status: "published", + slug, + version, + publicationStatus: "published", + }; } const pendingInsertArgs = { @@ -565,7 +571,13 @@ async function publishVersionForUserInternal( }; if (staged.status === "finalized" && staged.result) { - return staged.result; + return { + ...staged.result, + status: "published", + slug, + version, + publicationStatus: "published", + }; } return { diff --git a/convex/packages.public.test.ts b/convex/packages.public.test.ts index cd45408f..d10b2385 100644 --- a/convex/packages.public.test.ts +++ b/convex/packages.public.test.ts @@ -9009,7 +9009,12 @@ describe("packages public queries", () => { }, }, }), - ).resolves.toEqual({ ok: true, packageId: "packages:demo", releaseId: "releases:demo-1" }); + ).resolves.toEqual({ + ok: true, + packageId: "packages:demo", + releaseId: "releases:demo-1", + publicationStatus: "published", + }); expect(runMutation).toHaveBeenCalledWith( expect.anything(), diff --git a/convex/packages.ts b/convex/packages.ts index f8447e57..90260023 100644 --- a/convex/packages.ts +++ b/convex/packages.ts @@ -7899,7 +7899,13 @@ async function publishPackageImpl( } if (staged.status === "finalized" && staged.result) { - return inspectorFindings.length > 0 ? { ...staged.result, inspectorFindings } : staged.result; + const finalizedResult = { + ...staged.result, + publicationStatus: "published" as const, + }; + return inspectorFindings.length > 0 + ? { ...finalizedResult, inspectorFindings } + : finalizedResult; } return { @@ -8003,7 +8009,11 @@ async function publishPackageImpl( source: "publish", }); - return inspectorFindings.length > 0 ? { ...publishResult, inspectorFindings } : publishResult; + const publishedResult = { + ...publishResult, + publicationStatus: "published" as const, + }; + return inspectorFindings.length > 0 ? { ...publishedResult, inspectorFindings } : publishedResult; } function toPackageInspectorPublishResponseFinding( diff --git a/packages/clawhub/src/cli/commands/packages.test.ts b/packages/clawhub/src/cli/commands/packages.test.ts index 2f3a9bb0..7fcaae6f 100644 --- a/packages/clawhub/src/cli/commands/packages.test.ts +++ b/packages/clawhub/src/cli/commands/packages.test.ts @@ -1225,6 +1225,7 @@ describe("package commands", () => { ok: true, packageId: "pkg_1", releaseId: "rel_1", + publicationStatus: "published", }); const options = { @@ -1320,7 +1321,7 @@ describe("package commands", () => { }); expect(uiMocks.spinner.succeed).toHaveBeenCalledWith( - "OK. Uploaded @scope/pending-plugin@1.0.0; security checks are pending before it becomes public (rel_1)", + "Update submitted for @scope/pending-plugin@1.0.0; pending security scans before it becomes public.", ); expect(mockWrite).not.toHaveBeenCalled(); } finally { @@ -1371,11 +1372,54 @@ describe("package commands", () => { expect(output).toEqual( expect.objectContaining({ name: "@scope/json-pending-plugin", + status: "pending-publication", releaseId: "rel_1", publicationStatus: "pending", attemptId: "attempt_1", }), ); + expect(output.status).not.toBe("published"); + } finally { + await rm(workdir, { recursive: true, force: true }); + } + }); + + it("does not claim a package was published when the server omits publication status", async () => { + const workdir = await makeTmpWorkdir(); + try { + const folder = join(workdir, "unknown-status-plugin"); + await mkdir(join(folder, "dist"), { recursive: true }); + await writeFile( + join(folder, "package.json"), + makeCodePluginPackageJson({ + name: "@scope/unknown-status-plugin", + displayName: "Unknown Status Plugin", + version: "1.0.0", + files: ["dist", "openclaw.plugin.json"], + }), + "utf8", + ); + await writeFile( + join(folder, "openclaw.plugin.json"), + JSON.stringify({ id: "unknown.status.plugin" }), + "utf8", + ); + await writeFile(join(folder, "dist", "index.js"), "export const demo = true;\n", "utf8"); + httpMocks.apiRequestForm.mockResolvedValueOnce({ + ok: true, + packageId: "pkg_1", + releaseId: "rel_1", + }); + + await cmdPublishPackage(makeOpts(workdir), "unknown-status-plugin", { + owner: "@openclaw", + sourceRepo: "openclaw/unknown-status-plugin", + sourceCommit: "abc123", + }); + + expect(uiMocks.spinner.succeed).toHaveBeenCalledWith( + "Update submitted for @scope/unknown-status-plugin@1.0.0; publication status was not reported.", + ); } finally { await rm(workdir, { recursive: true, force: true }); } @@ -1601,6 +1645,7 @@ describe("package commands", () => { ok: true, packageId: "pkg_1", releaseId: "rel_1", + publicationStatus: "published", }); await cmdPublishPackage(makeOpts(workdir), packName, { @@ -2645,6 +2690,7 @@ describe("package commands", () => { ok: true, packageId: "pkg_1", releaseId: "rel_1", + publicationStatus: "published", inspectorFindings: [ { findingKind: "warning", diff --git a/packages/clawhub/src/cli/commands/packages.ts b/packages/clawhub/src/cli/commands/packages.ts index d2e8cd40..8551903c 100644 --- a/packages/clawhub/src/cli/commands/packages.ts +++ b/packages/clawhub/src/cli/commands/packages.ts @@ -975,14 +975,21 @@ export async function cmdPublishPackage( ApiV1PackagePublishResponseSchema, ); - const isPendingPublication = result.publicationStatus === "pending"; + const publicationStatus = result.publicationStatus; + const outputStatus = + publicationStatus === "pending" + ? "pending-publication" + : publicationStatus === "published" + ? "published" + : "submitted"; if (options.json) { process.stdout.write( `${JSON.stringify( { ...plan.output, + status: outputStatus, releaseId: result.releaseId, - publicationStatus: result.publicationStatus, + publicationStatus, attemptId: result.attemptId, inspectorFindings: result.inspectorFindings, }, @@ -991,11 +998,19 @@ export async function cmdPublishPackage( )}\n`, ); } else { - spinner?.succeed( - isPendingPublication - ? `OK. Uploaded ${plan.payload.name}@${plan.payload.version}; security checks are pending before it becomes public (${result.releaseId})` - : `OK. Published ${plan.payload.name}@${plan.payload.version} (${result.releaseId})`, - ); + if (publicationStatus === "pending") { + spinner?.succeed( + `Update submitted for ${plan.payload.name}@${plan.payload.version}; pending security scans before it becomes public.`, + ); + } else if (publicationStatus === "published") { + spinner?.succeed( + `OK. Published ${plan.payload.name}@${plan.payload.version} (${result.releaseId})`, + ); + } else { + spinner?.succeed( + `Update submitted for ${plan.payload.name}@${plan.payload.version}; publication status was not reported.`, + ); + } printPackageInspectorFindings(result); } } catch (error) { diff --git a/packages/clawhub/src/cli/commands/publish.test.ts b/packages/clawhub/src/cli/commands/publish.test.ts index 75f5a2f6..10ce1e21 100644 --- a/packages/clawhub/src/cli/commands/publish.test.ts +++ b/packages/clawhub/src/cli/commands/publish.test.ts @@ -82,6 +82,7 @@ describe("cmdPublish", () => { ok: true, skillId: "skill_1", versionId: "ver_2", + publicationStatus: "published", }); const result = await cmdPublish(makeOpts(workdir), "metadata-update", { @@ -117,6 +118,7 @@ describe("cmdPublish", () => { ok: true, skillId: "skill_1", versionId: "ver_1", + publicationStatus: "published", }); const result = await cmdPublish(makeOpts(workdir), "new-skill", {}); @@ -160,7 +162,67 @@ describe("cmdPublish", () => { attemptId: "attempt_1", }); expect(uiMocks.spinner.succeed).toHaveBeenCalledWith( - "OK. Uploaded pending-skill@1.0.0; security checks are pending before it becomes public (ver_pending)", + "Update submitted for pending-skill@1.0.0; pending security scans before it becomes public.", + ); + } finally { + await rm(workdir, { recursive: true, force: true }); + } + }); + + it("makes pending publication unambiguous in json output", async () => { + const workdir = await makeTmpWorkdir(); + const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + try { + const folder = join(workdir, "json-pending-skill"); + await mkdir(folder, { recursive: true }); + await writeFile(join(folder, "SKILL.md"), "# Skill\n", "utf8"); + httpMocks.apiRequest.mockRejectedValueOnce( + new Error("Skill not found or unavailable to this account."), + ); + httpMocks.apiRequestForm.mockResolvedValueOnce({ + ok: true, + skillId: "skill_1", + versionId: "ver_pending", + publicationStatus: "pending", + attemptId: "attempt_1", + }); + + await cmdPublish(makeOpts(workdir), "json-pending-skill", { json: true }); + + expect(uiMocks.spinner.succeed).not.toHaveBeenCalled(); + expect(writeSpy).toHaveBeenCalledTimes(1); + const output = JSON.parse(String(writeSpy.mock.calls[0]?.[0] ?? "")); + expect(output).toMatchObject({ + status: "pending-publication", + slug: "json-pending-skill", + version: "1.0.0", + publicationStatus: "pending", + attemptId: "attempt_1", + }); + expect(output.status).not.toBe("published"); + } finally { + writeSpy.mockRestore(); + await rm(workdir, { recursive: true, force: true }); + } + }); + + it("does not claim a skill was published when the server omits publication status", async () => { + const workdir = await makeTmpWorkdir(); + try { + const folder = join(workdir, "unknown-status-skill"); + await mkdir(folder, { recursive: true }); + await writeFile(join(folder, "SKILL.md"), "# Skill\n", "utf8"); + httpMocks.apiRequestForm.mockResolvedValueOnce({ + ok: true, + skillId: "skill_1", + versionId: "ver_1", + }); + + const result = await cmdPublish(makeOpts(workdir), "unknown-status-skill", {}); + + expect(result.status).toBe("submitted"); + expect(uiMocks.spinner.succeed).toHaveBeenCalledWith( + "Update submitted for unknown-status-skill@1.0.0; publication status was not reported.", ); } finally { await rm(workdir, { recursive: true, force: true }); @@ -181,6 +243,7 @@ describe("cmdPublish", () => { ok: true, skillId: "skill_1", versionId: "ver_2", + publicationStatus: "published", }); const result = await cmdPublish(makeOpts(workdir), "changed-skill", {}); @@ -210,6 +273,7 @@ describe("cmdPublish", () => { ok: true, skillId: "skill_1", versionId: "ver_2", + publicationStatus: "published", }); const result = await cmdPublish(makeOpts(workdir), "explicit-version", { diff --git a/packages/clawhub/src/cli/commands/publish.ts b/packages/clawhub/src/cli/commands/publish.ts index e9b6426e..743afb13 100644 --- a/packages/clawhub/src/cli/commands/publish.ts +++ b/packages/clawhub/src/cli/commands/publish.ts @@ -18,7 +18,7 @@ import { normalizeGitHubRepo } from "./github.js"; type SkillPublishResult = { ok: true; - status: "unchanged" | "would-publish" | "published" | "pending-publication"; + status: "unchanged" | "would-publish" | "submitted" | "published" | "pending-publication"; slug: string; displayName: string; folder: string; @@ -200,9 +200,14 @@ export async function cmdPublish( ApiV1PublishResponseSchema, ); - const isPendingPublication = result.publicationStatus === "pending"; + const publicationStatus = result.publicationStatus; const publishResult = buildPublishResult({ - status: isPendingPublication ? "pending-publication" : "published", + status: + publicationStatus === "pending" + ? "pending-publication" + : publicationStatus === "published" + ? "published" + : "submitted", slug, displayName, folder, @@ -214,11 +219,17 @@ export async function cmdPublish( publicationStatus: result.publicationStatus, attemptId: result.attemptId, }); - spinner?.succeed( - isPendingPublication - ? `OK. Uploaded ${slug}@${version}; security checks are pending before it becomes public (${result.versionId})` - : `OK. Published ${slug}@${version} (${result.versionId})`, - ); + if (publicationStatus === "pending") { + spinner?.succeed( + `Update submitted for ${slug}@${version}; pending security scans before it becomes public.`, + ); + } else if (publicationStatus === "published") { + spinner?.succeed(`OK. Published ${slug}@${version} (${result.versionId})`); + } else { + spinner?.succeed( + `Update submitted for ${slug}@${version}; publication status was not reported.`, + ); + } writePublishJsonIfRequested(options.json, publishResult); return publishResult; } catch (error) { diff --git a/packages/clawhub/src/cli/commands/sync.test.ts b/packages/clawhub/src/cli/commands/sync.test.ts index 74ecb38e..b21bc035 100644 --- a/packages/clawhub/src/cli/commands/sync.test.ts +++ b/packages/clawhub/src/cli/commands/sync.test.ts @@ -345,7 +345,9 @@ describe("cmdSync", () => { process.stdout.write("child publish output\n"); } return { + status: "published", version: (options as { version?: string } | undefined)?.version ?? "1.0.0", + publicationStatus: "published", }; }); @@ -368,6 +370,97 @@ describe("cmdSync", () => { ); }); + it("keeps pending sync submissions out of the published json summary", async () => { + interactive = false; + const stdoutWrite = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + mockApiRequest.mockImplementation(async (_registry: string, args: { path?: string }) => { + if (args.path?.startsWith("/api/v1/resolve?")) { + const u = new URL(`https://x.test${args.path}`); + const slug = u.searchParams.get("slug"); + if (slug === "new-skill") throw new Error("Skill not found"); + if (slug === "synced-skill") { + return { match: { version: "1.2.3" }, latestVersion: { version: "1.2.3" } }; + } + if (slug === "update-skill") { + return { match: null, latestVersion: { version: "1.0.0" } }; + } + } + throw new Error(`Unexpected apiRequest: ${String(args.path)}`); + }); + mockCmdPublish.mockImplementation( + (_opts: unknown, _folder: unknown, options?: { slug?: string; version?: string }) => + options?.slug === "new-skill" + ? { + status: "pending-publication", + version: options.version, + publicationStatus: "pending", + } + : { + status: "published", + version: options?.version, + publicationStatus: "published", + }, + ); + + let output = ""; + try { + await cmdSync(makeOpts(), { root: ["/scan"], all: true, json: true }, false); + output = String(stdoutWrite.mock.calls[0]?.[0] ?? ""); + } finally { + stdoutWrite.mockRestore(); + } + + const parsed = JSON.parse(output); + expect(parsed.summary).toMatchObject({ published: 1, submitted: 1, failed: 0 }); + expect(parsed.published).toEqual([ + expect.objectContaining({ slug: "update-skill", version: "1.0.1" }), + ]); + expect(parsed.submitted).toEqual([ + expect.objectContaining({ + slug: "new-skill", + version: "1.0.0", + status: "pending-publication", + publicationStatus: "pending", + }), + ]); + }); + + it("does not call pending sync submissions published in human summaries", async () => { + interactive = false; + mockApiRequest.mockImplementation(async (_registry: string, args: { path?: string }) => { + if (args.path?.startsWith("/api/v1/resolve?")) { + const u = new URL(`https://x.test${args.path}`); + const slug = u.searchParams.get("slug"); + if (slug === "new-skill") throw new Error("Skill not found"); + if (slug === "synced-skill") { + return { match: { version: "1.2.3" }, latestVersion: { version: "1.2.3" } }; + } + if (slug === "update-skill") { + return { match: null, latestVersion: { version: "1.0.0" } }; + } + } + throw new Error(`Unexpected apiRequest: ${String(args.path)}`); + }); + mockCmdPublish.mockImplementation( + (_opts: unknown, _folder: unknown, options?: { slug?: string; version?: string }) => + options?.slug === "new-skill" + ? { + status: "pending-publication", + version: options.version, + publicationStatus: "pending", + } + : { + status: "published", + version: options?.version, + publicationStatus: "published", + }, + ); + + await cmdSync(makeOpts(), { root: ["/scan"], all: true }, false); + + expect(mockOutro).toHaveBeenCalledWith("Published 1 skill(s). Submitted 1 update(s)."); + }); + it("does not report a raced unchanged publish as uploaded", async () => { interactive = false; const stdoutWrite = vi.spyOn(process.stdout, "write").mockImplementation(() => true); diff --git a/packages/clawhub/src/cli/commands/sync.ts b/packages/clawhub/src/cli/commands/sync.ts index 5c3f9e5b..6a3e1d3d 100644 --- a/packages/clawhub/src/cli/commands/sync.ts +++ b/packages/clawhub/src/cli/commands/sync.ts @@ -197,6 +197,7 @@ export async function cmdSync(opts: GlobalOpts, options: SyncOptions, inputAllow alreadySynced: synced.map(formatSyncedJson), wouldPublish, published: [], + submitted: [], failed: [], }), ); @@ -209,6 +210,13 @@ export async function cmdSync(opts: GlobalOpts, options: SyncOptions, inputAllow const tags = options.tags ?? "latest"; const failedUploads: Array<{ slug: string; message: string }> = []; const published: Array<{ slug: string; folder: string; version: string }> = []; + const submitted: Array<{ + slug: string; + folder: string; + version: string; + status: "pending-publication" | "submitted"; + publicationStatus?: "pending"; + }> = []; const racedNoOps: Array<{ slug: string; folder: string; version: string }> = []; for (const { skill, source } of plannedPublishes) { @@ -254,11 +262,20 @@ export async function cmdSync(opts: GlobalOpts, options: SyncOptions, inputAllow }); continue; } - published.push({ + const output = { slug: skill.slug, folder: skill.folder, version: result?.version ?? publishVersion, - }); + }; + if (result?.status === "published") { + published.push(output); + } else { + submitted.push({ + ...output, + status: result?.status === "pending-publication" ? "pending-publication" : "submitted", + ...(result?.publicationStatus === "pending" ? { publicationStatus: "pending" } : {}), + }); + } } catch (error) { failedUploads.push({ slug: skill.slug, message: formatError(error) }); } @@ -277,6 +294,7 @@ export async function cmdSync(opts: GlobalOpts, options: SyncOptions, inputAllow alreadySynced: [...synced.map(formatSyncedJson), ...racedNoOps], wouldPublish: [], published, + submitted, failed: failedUploads, }), ); @@ -290,9 +308,7 @@ export async function cmdSync(opts: GlobalOpts, options: SyncOptions, inputAllow 20, ), ); - outro( - `Published ${published.length} of ${selected.length} skill(s). ${failedUploads.length} failed.`, - ); + outro(formatSyncPublishSummary({ published, submitted, selected, failedUploads })); process.exitCode = 1; return; } @@ -309,6 +325,7 @@ export async function cmdSync(opts: GlobalOpts, options: SyncOptions, inputAllow alreadySynced: [...synced.map(formatSyncedJson), ...racedNoOps], wouldPublish: [], published, + submitted, failed: [], }), ); @@ -316,11 +333,9 @@ export async function cmdSync(opts: GlobalOpts, options: SyncOptions, inputAllow } if (racedNoOps.length > 0) { - outro( - `Published ${published.length} of ${selected.length} skill(s). ${racedNoOps.length} already synced.`, - ); + outro(formatSyncPublishSummary({ published, submitted, selected, racedNoOps })); } else { - outro(`Published ${published.length} skill(s).`); + outro(formatSyncPublishSummary({ published, submitted })); } } @@ -346,6 +361,7 @@ function writeNoActionOutput(params: { alreadySynced: params.synced.map(formatSyncedJson), wouldPublish: [], published: [], + submitted: [], failed: [], }), ); @@ -479,6 +495,13 @@ function buildSyncJsonOutput(params: { alreadySynced: Array<{ slug: string; folder: string; version: string }>; wouldPublish: Array>; published: Array<{ slug: string; folder: string; version: string }>; + submitted: Array<{ + slug: string; + folder: string; + version: string; + status: "pending-publication" | "submitted"; + publicationStatus?: "pending"; + }>; failed: Array<{ slug: string; message: string }>; }) { const skipped = params.duplicates.map((duplicate) => ({ @@ -495,18 +518,41 @@ function buildSyncJsonOutput(params: { summary: { wouldPublish: params.wouldPublish.length, published: params.published.length, + submitted: params.submitted.length, alreadySynced: params.alreadySynced.length, skipped: skipped.length, failed: params.failed.length, }, wouldPublish: params.wouldPublish, published: params.published, + submitted: params.submitted, alreadySynced: params.alreadySynced, skipped, failed: params.failed, }; } +function formatSyncPublishSummary(params: { + published: unknown[]; + submitted: unknown[]; + selected?: unknown[]; + racedNoOps?: unknown[]; + failedUploads?: unknown[]; +}) { + const selectedSuffix = params.selected ? ` of ${params.selected.length}` : ""; + const parts = [`Published ${params.published.length}${selectedSuffix} skill(s).`]; + if (params.submitted.length > 0) { + parts.push(`Submitted ${params.submitted.length} update(s).`); + } + if (params.racedNoOps && params.racedNoOps.length > 0) { + parts.push(`${params.racedNoOps.length} already synced.`); + } + if (params.failedUploads && params.failedUploads.length > 0) { + parts.push(`${params.failedUploads.length} failed.`); + } + return parts.join(" "); +} + function writeSyncJson(value: unknown) { process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); }