@nebutra/play-marketplace 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,84 @@
1
+ import { mkdtemp, rm } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+ import { PlayMarketplace, readPlayMarketplaceDebug, validatePlayPackage } from "./index";
6
+
7
+ let root: string | undefined;
8
+ let marketplace: PlayMarketplace | undefined;
9
+
10
+ const skillMarkdown = [
11
+ "---",
12
+ "name: cold_email_pro",
13
+ "kind: play",
14
+ "version: 1.0.0",
15
+ "description: Cold email campaign play",
16
+ "required_skills:",
17
+ " - content_store.write",
18
+ "sub_agents:",
19
+ " - role: writer",
20
+ " allowed_skills: [content_store.write]",
21
+ "---",
22
+ "",
23
+ "## What this play does",
24
+ "",
25
+ "Builds a reviewed campaign draft.",
26
+ ].join("\n");
27
+
28
+ afterEach(async () => {
29
+ if (marketplace) await marketplace.close();
30
+ if (root) await rm(root, { recursive: true, force: true });
31
+ root = undefined;
32
+ marketplace = undefined;
33
+ });
34
+
35
+ async function open(): Promise<PlayMarketplace> {
36
+ root = await mkdtemp(join(tmpdir(), "play-marketplace-"));
37
+ marketplace = await PlayMarketplace.open(root, { tenantId: "tenant_a" });
38
+ return marketplace;
39
+ }
40
+
41
+ describe("play-marketplace", () => {
42
+ it("validates Plays through play-loader instead of local YAML parsing", () => {
43
+ const report = validatePlayPackage({ skillMarkdown });
44
+
45
+ expect(report.ok).toBe(true);
46
+ expect(report.playName).toBe("cold_email_pro");
47
+ expect(report.requiredSkills).toContain("content_store.write");
48
+ });
49
+
50
+ it("publishes, searches, and installs signed Play registry records", async () => {
51
+ const runtime = await open();
52
+ const published = await runtime.publish({
53
+ skillMarkdown,
54
+ visibility: "public",
55
+ pricing: { model: "free" },
56
+ invitedAuthor: true,
57
+ });
58
+ const results = await runtime.search({ query: "email", sort: "success_rate" });
59
+ const install = await runtime.install(published.playId, "1.0.0");
60
+
61
+ expect(published.registryPath).toBe(`marketplace/plays/${published.playId}@1.0.0.json`);
62
+ expect(published.quality.verified).toBe(false);
63
+ expect(results.items.map((item) => item.playId)).toContain(published.playId);
64
+ expect(install.status).toBe("installed");
65
+ expect(install.registeredSkill).toBe("cold_email_pro");
66
+ });
67
+
68
+ it("blocks uninvited paid publishing during controlled marketplace launch", async () => {
69
+ const runtime = await open();
70
+
71
+ await expect(
72
+ runtime.publish({
73
+ skillMarkdown,
74
+ visibility: "public",
75
+ pricing: { model: "subscription", amountUsd: 29, interval: "month" },
76
+ invitedAuthor: false,
77
+ }),
78
+ ).rejects.toMatchObject({
79
+ capability: "play-marketplace",
80
+ suggestion: expect.stringContaining("invited"),
81
+ });
82
+ await expect(readPlayMarketplaceDebug(root)).resolves.toEqual(expect.any(Array));
83
+ });
84
+ });
package/src/index.ts ADDED
@@ -0,0 +1,281 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { appendCapabilityDebug, readCapabilityDebug } from "@nebutra/capability-kit/debug";
4
+ import { ContentStore } from "@nebutra/content-store";
5
+ import { CapabilityError } from "@nebutra/errors";
6
+ import { EventLog } from "@nebutra/event-log";
7
+ import { assetId } from "@nebutra/generation-context";
8
+ import { parsePlayMarkdown } from "@nebutra/play-loader";
9
+
10
+ export type Visibility = "private" | "public";
11
+ export type Pricing =
12
+ | { readonly model: "free" }
13
+ | { readonly model: "one_time"; readonly amountUsd: number }
14
+ | {
15
+ readonly model: "subscription";
16
+ readonly amountUsd: number;
17
+ readonly interval: "month" | "year";
18
+ }
19
+ | { readonly model: "per_run"; readonly amountUsd: number };
20
+
21
+ export interface ValidatePlayPackageRequest {
22
+ readonly skillMarkdown: string;
23
+ }
24
+
25
+ export interface PlayValidationReport {
26
+ readonly ok: boolean;
27
+ readonly playName: string;
28
+ readonly version: string;
29
+ readonly requiredSkills: readonly string[];
30
+ readonly subAgents: readonly string[];
31
+ }
32
+
33
+ export interface PublishPlayRequest {
34
+ readonly skillMarkdown: string;
35
+ readonly visibility: Visibility;
36
+ readonly pricing: Pricing;
37
+ readonly invitedAuthor: boolean;
38
+ }
39
+
40
+ export interface PublishedPlay {
41
+ readonly playId: string;
42
+ readonly version: string;
43
+ readonly registryPath: string;
44
+ readonly visibility: Visibility;
45
+ readonly pricing: Pricing;
46
+ readonly quality: {
47
+ readonly verified: boolean;
48
+ readonly successRate: number;
49
+ readonly avgCostUsd: number;
50
+ readonly avgDurationMinutes: number;
51
+ };
52
+ readonly eventId: string;
53
+ }
54
+
55
+ export interface MarketplaceSearchRequest {
56
+ readonly query: string;
57
+ readonly sort: "success_rate" | "new";
58
+ }
59
+
60
+ export interface MarketplaceSearchResult {
61
+ readonly items: readonly PublishedPlay[];
62
+ }
63
+
64
+ export interface InstalledPlay {
65
+ readonly playId: string;
66
+ readonly version: string;
67
+ readonly status: "installed";
68
+ readonly registeredSkill: string;
69
+ readonly installPath: string;
70
+ }
71
+
72
+ export interface PlayMarketplaceDoctorReport {
73
+ readonly capability: "play-marketplace";
74
+ readonly ok: boolean;
75
+ readonly checkedAt: string;
76
+ readonly features: readonly string[];
77
+ readonly suggestion?: string;
78
+ }
79
+
80
+ export interface PlayMarketplaceOptions {
81
+ readonly tenantId?: string;
82
+ readonly debugRoot?: string;
83
+ readonly contentStore?: ContentStore;
84
+ readonly eventLog?: EventLog;
85
+ }
86
+
87
+ function requireTenant(value: string | undefined): string {
88
+ if (!value?.trim()) {
89
+ throw new CapabilityError("play-marketplace", "Play Marketplace requires tenant context", {
90
+ suggestion: "Pass tenantId so registry and install artifacts stay tenant-scoped.",
91
+ statusCode: 400,
92
+ });
93
+ }
94
+ return value;
95
+ }
96
+
97
+ function isPaid(pricing: Pricing): boolean {
98
+ return pricing.model !== "free";
99
+ }
100
+
101
+ export function validatePlayPackage(request: ValidatePlayPackageRequest): PlayValidationReport {
102
+ const play = parsePlayMarkdown(request.skillMarkdown);
103
+ return {
104
+ ok: true,
105
+ playName: play.meta.name,
106
+ version: play.meta.version,
107
+ requiredSkills: play.requiredSkills,
108
+ subAgents: play.subAgents.map((agent) => agent.role),
109
+ };
110
+ }
111
+
112
+ export class PlayMarketplace {
113
+ readonly #tenantId: string;
114
+ readonly #debugRoot: string;
115
+ readonly #contentStore: ContentStore;
116
+ readonly #eventLog: EventLog;
117
+ readonly #plays = new Map<string, PublishedPlay & { readonly skillMarkdown: string }>();
118
+
119
+ private constructor(
120
+ options: Required<Pick<PlayMarketplaceOptions, "tenantId" | "contentStore" | "eventLog">> &
121
+ Pick<PlayMarketplaceOptions, "debugRoot">,
122
+ ) {
123
+ this.#tenantId = requireTenant(options.tenantId);
124
+ this.#debugRoot = options.debugRoot ?? process.cwd();
125
+ this.#contentStore = options.contentStore;
126
+ this.#eventLog = options.eventLog;
127
+ }
128
+
129
+ static async open(
130
+ root = ".nebutra/play-marketplace",
131
+ options: Omit<PlayMarketplaceOptions, "contentStore" | "eventLog"> = {},
132
+ ): Promise<PlayMarketplace> {
133
+ const tenantId = options.tenantId ?? "local";
134
+ await mkdir(root, { recursive: true });
135
+ const contentStore = await ContentStore.open(join(root, "content"), { tenantId });
136
+ const eventLog = await EventLog.open(join(root, "event-log"), { tenantId });
137
+ return new PlayMarketplace({ ...options, tenantId, contentStore, eventLog });
138
+ }
139
+
140
+ async publish(request: PublishPlayRequest): Promise<PublishedPlay> {
141
+ if (isPaid(request.pricing) && !request.invitedAuthor) {
142
+ throw new CapabilityError(
143
+ "play-marketplace",
144
+ "Paid Play publishing is limited during controlled launch",
145
+ {
146
+ suggestion:
147
+ "Use an invited author account or publish a free Play until paid publishing opens.",
148
+ statusCode: 403,
149
+ },
150
+ );
151
+ }
152
+
153
+ const validation = validatePlayPackage({ skillMarkdown: request.skillMarkdown });
154
+ const playId = validation.playName;
155
+ const registryPath = `marketplace/plays/${playId}@${validation.version}.json`;
156
+ const record = {
157
+ playId,
158
+ version: validation.version,
159
+ visibility: request.visibility,
160
+ pricing: request.pricing,
161
+ quality: {
162
+ verified: false,
163
+ successRate: 0,
164
+ avgCostUsd: 0,
165
+ avgDurationMinutes: 0,
166
+ },
167
+ requiredSkills: validation.requiredSkills,
168
+ subAgents: validation.subAgents,
169
+ signature: assetId("play_sig", `${playId}_${validation.version}`),
170
+ };
171
+ const snapshot = `${JSON.stringify(record, null, 2)}\n`;
172
+ await this.#contentStore.write(registryPath, snapshot);
173
+ const eventId = await this.#eventLog.commit({
174
+ traceId: assetId("play_marketplace_publish", `${playId}_${validation.version}`),
175
+ kind: "content_write",
176
+ affected: [registryPath],
177
+ parent: null,
178
+ snapshot: { [registryPath]: snapshot },
179
+ });
180
+ const published: PublishedPlay & { readonly skillMarkdown: string } = {
181
+ playId,
182
+ version: validation.version,
183
+ registryPath,
184
+ visibility: request.visibility,
185
+ pricing: request.pricing,
186
+ quality: record.quality,
187
+ eventId,
188
+ skillMarkdown: request.skillMarkdown,
189
+ };
190
+ this.#plays.set(`${playId}@${validation.version}`, published);
191
+ await this.#debug({
192
+ type: "publish",
193
+ tenantId: this.#tenantId,
194
+ playId,
195
+ version: validation.version,
196
+ });
197
+ return published;
198
+ }
199
+
200
+ async search(request: MarketplaceSearchRequest): Promise<MarketplaceSearchResult> {
201
+ const probe = request.query.toLowerCase();
202
+ const items = [...this.#plays.values()]
203
+ .filter(
204
+ (play) =>
205
+ play.playId.toLowerCase().includes(probe) ||
206
+ play.skillMarkdown.toLowerCase().includes(probe),
207
+ )
208
+ .sort((left, right) =>
209
+ request.sort === "success_rate"
210
+ ? right.quality.successRate - left.quality.successRate ||
211
+ left.playId.localeCompare(right.playId)
212
+ : right.eventId.localeCompare(left.eventId),
213
+ );
214
+ await this.#debug({
215
+ type: "search",
216
+ tenantId: this.#tenantId,
217
+ query: request.query,
218
+ hits: items.length,
219
+ });
220
+ return { items };
221
+ }
222
+
223
+ async install(playId: string, version: string): Promise<InstalledPlay> {
224
+ const key = `${playId}@${version}`;
225
+ const play = this.#plays.get(key);
226
+ if (!play) {
227
+ throw new CapabilityError("play-marketplace", "Play registry record not found", {
228
+ suggestion: "Search the marketplace and install an available version.",
229
+ metadata: { playId, version },
230
+ statusCode: 404,
231
+ });
232
+ }
233
+ const installPath = `marketplace/installed/${playId}@${version}.json`;
234
+ const install: InstalledPlay = {
235
+ playId,
236
+ version,
237
+ status: "installed",
238
+ registeredSkill: playId,
239
+ installPath,
240
+ };
241
+ const snapshot = `${JSON.stringify(install, null, 2)}\n`;
242
+ await this.#contentStore.write(installPath, snapshot);
243
+ await this.#eventLog.commit({
244
+ traceId: assetId("play_marketplace_install", `${playId}_${version}`),
245
+ kind: "content_write",
246
+ affected: [installPath],
247
+ parent: play.eventId,
248
+ snapshot: { [installPath]: snapshot },
249
+ });
250
+ await this.#debug({ type: "install", tenantId: this.#tenantId, playId, version });
251
+ return install;
252
+ }
253
+
254
+ async doctor(): Promise<PlayMarketplaceDoctorReport> {
255
+ const report: PlayMarketplaceDoctorReport = {
256
+ capability: "play-marketplace",
257
+ ok: true,
258
+ checkedAt: new Date().toISOString(),
259
+ features: ["validate-skill", "publish", "search", "install", "paid-launch-gate"],
260
+ suggestion:
261
+ "Local registry artifacts are active; remote registry, billing, and verified review queues remain handoffs.",
262
+ };
263
+ await this.#debug({ type: "doctor", tenantId: this.#tenantId, plays: this.#plays.size });
264
+ return report;
265
+ }
266
+
267
+ async close(): Promise<void> {
268
+ await this.#contentStore.close();
269
+ }
270
+
271
+ async #debug(entry: Record<string, unknown>): Promise<void> {
272
+ await appendCapabilityDebug("play-marketplace", entry, { root: this.#debugRoot });
273
+ }
274
+ }
275
+
276
+ export async function readPlayMarketplaceDebug(
277
+ root = process.cwd(),
278
+ limit = 20,
279
+ ): Promise<unknown[]> {
280
+ return readCapabilityDebug("play-marketplace", { root, limit });
281
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "rootDir": "src",
6
+ "types": ["node", "vitest/globals"]
7
+ },
8
+ "include": ["src/**/*.ts"]
9
+ }
package/tsup.config.ts ADDED
@@ -0,0 +1,9 @@
1
+ import { defineConfig } from "tsup";
2
+
3
+ export default defineConfig({
4
+ clean: true,
5
+ dts: true,
6
+ entry: ["src/index.ts", "src/cli.ts"],
7
+ format: ["esm"],
8
+ sourcemap: true,
9
+ });