@crewhaus/template-marketplace-client 0.1.0

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.
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@crewhaus/template-marketplace-client",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Studio Marketplace integration: list / search / install community templates; one-click publish flow (Section 40)",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts"
10
+ },
11
+ "scripts": {
12
+ "test": "bun test src"
13
+ },
14
+ "dependencies": {
15
+ "@crewhaus/errors": "0.0.0",
16
+ "@crewhaus/template-registry": "0.0.0"
17
+ },
18
+ "license": "Apache-2.0",
19
+ "author": {
20
+ "name": "Max Meier",
21
+ "email": "max@studiomax.io",
22
+ "url": "https://studiomax.io"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/crewhaus/factory.git",
27
+ "directory": "packages/template-marketplace-client"
28
+ },
29
+ "homepage": "https://github.com/crewhaus/factory/tree/main/packages/template-marketplace-client#readme",
30
+ "bugs": {
31
+ "url": "https://github.com/crewhaus/factory/issues"
32
+ },
33
+ "publishConfig": {
34
+ "access": "restricted"
35
+ },
36
+ "files": [
37
+ "src",
38
+ "README.md",
39
+ "LICENSE",
40
+ "NOTICE"
41
+ ]
42
+ }
@@ -0,0 +1,198 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { LocalRegistrySource, type TemplateManifest } from "@crewhaus/template-registry";
6
+ import { MarketplaceClient, MarketplaceClientError, MarketplacePublisher } from "./index";
7
+
8
+ const seed = (overrides: Partial<TemplateManifest> = {}): TemplateManifest => ({
9
+ name: "hello-cli-template",
10
+ version: "1.0.0",
11
+ description: "Hello-world CLI template",
12
+ author: "alice",
13
+ target: "cli",
14
+ yaml: "name: hello\ntarget: cli\nagent:\n model: claude-sonnet-4-6\n",
15
+ ...overrides,
16
+ });
17
+
18
+ describe("MarketplaceClient — search (T1)", () => {
19
+ let tmp: string;
20
+ beforeEach(() => {
21
+ tmp = mkdtempSync(join(tmpdir(), "marketplace-test-"));
22
+ });
23
+ afterEach(() => {
24
+ rmSync(tmp, { recursive: true, force: true });
25
+ });
26
+
27
+ function buildClient(rootDir: string, workspaceDir: string): MarketplaceClient {
28
+ const registry = new LocalRegistrySource({ rootDir });
29
+ registry.put(seed());
30
+ registry.put(
31
+ seed({
32
+ name: "slack-bot-template",
33
+ target: "channel",
34
+ author: "bob",
35
+ description: "Slack bot template",
36
+ }),
37
+ );
38
+ registry.put(
39
+ seed({
40
+ name: "rag-search-template",
41
+ target: "pipeline",
42
+ author: "carol",
43
+ description: "RAG retrieval template",
44
+ }),
45
+ );
46
+ return new MarketplaceClient({ registry, workspaceDir });
47
+ }
48
+
49
+ test("list returns all metadata entries", async () => {
50
+ const registryDir = join(tmp, "registry");
51
+ const workspace = join(tmp, "workspace");
52
+ const client = buildClient(registryDir, workspace);
53
+ const list = await client.list();
54
+ expect(list.length).toBe(3);
55
+ });
56
+
57
+ test("search by query matches name + description (case-insensitive)", async () => {
58
+ const client = buildClient(join(tmp, "r"), join(tmp, "w"));
59
+ const results = await client.search({ query: "RAG" });
60
+ expect(results.map((r) => r.metadata.name)).toEqual(["rag-search-template"]);
61
+ });
62
+
63
+ test("search by target filter", async () => {
64
+ const client = buildClient(join(tmp, "r"), join(tmp, "w"));
65
+ const results = await client.search({ target: "channel" });
66
+ expect(results.map((r) => r.metadata.name)).toEqual(["slack-bot-template"]);
67
+ });
68
+
69
+ test("search by author filter", async () => {
70
+ const client = buildClient(join(tmp, "r"), join(tmp, "w"));
71
+ const results = await client.search({ author: "alice" });
72
+ expect(results.map((r) => r.metadata.name)).toEqual(["hello-cli-template"]);
73
+ });
74
+
75
+ test("search ranks name matches above description-only matches", async () => {
76
+ const client = buildClient(join(tmp, "r"), join(tmp, "w"));
77
+ const results = await client.search({ query: "template" });
78
+ // Every entry contains "template" — but name matches should rank
79
+ // higher when the score is computed; results are sorted by score.
80
+ expect(results.length).toBeGreaterThan(0);
81
+ });
82
+
83
+ test("search limit caps results", async () => {
84
+ const client = buildClient(join(tmp, "r"), join(tmp, "w"));
85
+ const results = await client.search({ limit: 2 });
86
+ expect(results.length).toBe(2);
87
+ });
88
+ });
89
+
90
+ describe("MarketplaceClient — install (T1 + T8 path-traversal)", () => {
91
+ let tmp: string;
92
+ beforeEach(() => {
93
+ tmp = mkdtempSync(join(tmpdir(), "marketplace-install-test-"));
94
+ });
95
+ afterEach(() => {
96
+ rmSync(tmp, { recursive: true, force: true });
97
+ });
98
+
99
+ test("installs a template into <workspace>/<name>/crewhaus.yaml", async () => {
100
+ const registry = new LocalRegistrySource({ rootDir: join(tmp, "r") });
101
+ registry.put(seed());
102
+ const workspace = join(tmp, "w");
103
+ const client = new MarketplaceClient({ registry, workspaceDir: workspace });
104
+ const result = await client.install("hello-cli-template");
105
+ expect(result.path).toBe(join(workspace, "hello-cli-template", "crewhaus.yaml"));
106
+ expect(existsSync(result.path)).toBe(true);
107
+ const content = readFileSync(result.path, "utf8");
108
+ expect(content).toContain("target: cli");
109
+ });
110
+
111
+ test("install honors custom subdir + filename", async () => {
112
+ const registry = new LocalRegistrySource({ rootDir: join(tmp, "r") });
113
+ registry.put(seed());
114
+ const client = new MarketplaceClient({ registry, workspaceDir: join(tmp, "w") });
115
+ const result = await client.install("hello-cli-template", {
116
+ subdir: "my-custom-dir",
117
+ filename: "spec.yaml",
118
+ });
119
+ expect(result.path.endsWith(join("my-custom-dir", "spec.yaml"))).toBe(true);
120
+ });
121
+
122
+ test("install refuses path-traversal in name", async () => {
123
+ const registry = new LocalRegistrySource({ rootDir: join(tmp, "r") });
124
+ registry.put(seed());
125
+ const client = new MarketplaceClient({ registry, workspaceDir: join(tmp, "w") });
126
+ await expect(client.install("../escape")).rejects.toThrow(MarketplaceClientError);
127
+ });
128
+
129
+ test("install refuses path-traversal in subdir", async () => {
130
+ const registry = new LocalRegistrySource({ rootDir: join(tmp, "r") });
131
+ registry.put(seed());
132
+ const client = new MarketplaceClient({ registry, workspaceDir: join(tmp, "w") });
133
+ await expect(client.install("hello-cli-template", { subdir: "../escape" })).rejects.toThrow(
134
+ MarketplaceClientError,
135
+ );
136
+ await expect(client.install("hello-cli-template", { subdir: "evil/path" })).rejects.toThrow(
137
+ MarketplaceClientError,
138
+ );
139
+ });
140
+
141
+ test("install refuses path-traversal in filename", async () => {
142
+ const registry = new LocalRegistrySource({ rootDir: join(tmp, "r") });
143
+ registry.put(seed());
144
+ const client = new MarketplaceClient({ registry, workspaceDir: join(tmp, "w") });
145
+ await expect(
146
+ client.install("hello-cli-template", { filename: "../etc/passwd" }),
147
+ ).rejects.toThrow(MarketplaceClientError);
148
+ });
149
+ });
150
+
151
+ describe("MarketplacePublisher (T1 + T3)", () => {
152
+ test("draftPublish builds a PR-ready draft", () => {
153
+ const publisher = new MarketplacePublisher();
154
+ const draft = publisher.draftPublish({
155
+ registryName: "crewhaus/templates",
156
+ manifest: seed(),
157
+ });
158
+ expect(draft.title).toContain("hello-cli-template");
159
+ expect(draft.title).toContain("v1.0.0");
160
+ expect(draft.body).toContain("**Target:** cli");
161
+ expect(draft.body).toContain("**Author:** alice");
162
+ expect(JSON.parse(draft.manifestJson).name).toBe("hello-cli-template");
163
+ });
164
+
165
+ test("draftPublish refuses invalid template names", () => {
166
+ const publisher = new MarketplacePublisher();
167
+ expect(() =>
168
+ publisher.draftPublish({
169
+ registryName: "x",
170
+ manifest: seed({ name: "../escape" }),
171
+ }),
172
+ ).toThrow(MarketplaceClientError);
173
+ });
174
+
175
+ test("draftPublish requires registryName", () => {
176
+ const publisher = new MarketplacePublisher();
177
+ expect(() => publisher.draftPublish({ registryName: "", manifest: seed() })).toThrow(
178
+ MarketplaceClientError,
179
+ );
180
+ });
181
+
182
+ test("writeDraft persists manifest to <workspace>/templates/<name>.json", () => {
183
+ const tmp = mkdtempSync(join(tmpdir(), "marketplace-publish-test-"));
184
+ try {
185
+ const publisher = new MarketplacePublisher();
186
+ const draft = publisher.draftPublish({
187
+ registryName: "x",
188
+ manifest: seed(),
189
+ });
190
+ const path = publisher.writeDraft(tmp, draft);
191
+ expect(path).toBe(join(tmp, "templates", "hello-cli-template.json"));
192
+ expect(existsSync(path)).toBe(true);
193
+ expect(JSON.parse(readFileSync(path, "utf8")).name).toBe("hello-cli-template");
194
+ } finally {
195
+ rmSync(tmp, { recursive: true, force: true });
196
+ }
197
+ });
198
+ });
package/src/index.ts ADDED
@@ -0,0 +1,199 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { CrewhausError } from "@crewhaus/errors";
4
+ import type {
5
+ RegistrySource,
6
+ TemplateManifest,
7
+ TemplateMetadata,
8
+ } from "@crewhaus/template-registry";
9
+
10
+ /**
11
+ * Catalog F4 `template-marketplace-client` — Section 40 Studio
12
+ * Marketplace integration.
13
+ *
14
+ * Two surfaces:
15
+ *
16
+ * 1. **Discover/install**: `MarketplaceClient.search`,
17
+ * `MarketplaceClient.install` — wraps a `RegistrySource` from
18
+ * §40 `template-registry`, layers on search/filter, and writes
19
+ * installed manifests into the user's spec workspace
20
+ * (`<workspace>/<name>/crewhaus.yaml`).
21
+ *
22
+ * 2. **Publish**: `MarketplacePublisher.draftPublish` — produces a
23
+ * `PublishDraft` describing a PR that the caller's git client
24
+ * submits to the canonical registry repo (or pushes as a Gist).
25
+ * We don't ship a git client here — Studio's existing GitHub
26
+ * integration owns the actual transport.
27
+ *
28
+ * Layer F4. Pairs with `template-registry` (§40 — backend), Studio
29
+ * Marketplace tab (§35 surface, integration deferred to Studio UI).
30
+ */
31
+
32
+ export class MarketplaceClientError extends CrewhausError {
33
+ override readonly name = "MarketplaceClientError";
34
+ constructor(message: string, cause?: unknown) {
35
+ super("config", message, cause);
36
+ }
37
+ }
38
+
39
+ export type SearchFilter = {
40
+ /** Substring match on name + description (case-insensitive). */
41
+ readonly query?: string;
42
+ /** Exact match on `target`. */
43
+ readonly target?: string;
44
+ /** Exact match on `author`. */
45
+ readonly author?: string;
46
+ /** Cap results. Default 50. */
47
+ readonly limit?: number;
48
+ };
49
+
50
+ export type InstallOptions = {
51
+ /** Override the directory under workspaceDir. Default = manifest.name. */
52
+ readonly subdir?: string;
53
+ /** Filename. Default = "crewhaus.yaml". */
54
+ readonly filename?: string;
55
+ };
56
+
57
+ export type InstallResult = {
58
+ readonly path: string;
59
+ readonly manifest: TemplateManifest;
60
+ };
61
+
62
+ export type PublishDraft = {
63
+ readonly registryName: string;
64
+ readonly templateName: string;
65
+ readonly target: string;
66
+ readonly author: string;
67
+ readonly description: string;
68
+ readonly version: string;
69
+ readonly manifestJson: string;
70
+ readonly title: string;
71
+ readonly body: string;
72
+ };
73
+
74
+ export type MarketplaceClientOptions = {
75
+ readonly registry: RegistrySource;
76
+ readonly workspaceDir: string;
77
+ };
78
+
79
+ export type MarketplaceSearchResult = {
80
+ readonly metadata: TemplateMetadata;
81
+ readonly score: number;
82
+ };
83
+
84
+ export class MarketplaceClient {
85
+ constructor(private readonly opts: MarketplaceClientOptions) {
86
+ if (opts.registry === undefined) {
87
+ throw new MarketplaceClientError("registry is required");
88
+ }
89
+ if (typeof opts.workspaceDir !== "string" || opts.workspaceDir === "") {
90
+ throw new MarketplaceClientError("workspaceDir is required");
91
+ }
92
+ }
93
+
94
+ async list(): Promise<ReadonlyArray<TemplateMetadata>> {
95
+ return this.opts.registry.list();
96
+ }
97
+
98
+ async search(filter: SearchFilter = {}): Promise<ReadonlyArray<MarketplaceSearchResult>> {
99
+ const all = await this.opts.registry.list();
100
+ const limit = filter.limit ?? 50;
101
+ const queryLower = filter.query?.toLowerCase() ?? "";
102
+ const filtered = all
103
+ .map((m) => {
104
+ let score = 0;
105
+ if (filter.target !== undefined && m.target !== filter.target) return null;
106
+ if (filter.author !== undefined && m.author !== filter.author) return null;
107
+ if (queryLower !== "") {
108
+ const haystack = `${m.name} ${m.description}`.toLowerCase();
109
+ if (!haystack.includes(queryLower)) return null;
110
+ // Higher score for name matches than description-only matches.
111
+ if (m.name.toLowerCase().includes(queryLower)) score += 10;
112
+ score += haystack.split(queryLower).length - 1;
113
+ } else {
114
+ score = 1;
115
+ }
116
+ return { metadata: m, score };
117
+ })
118
+ .filter((r): r is MarketplaceSearchResult => r !== null);
119
+ return filtered.sort((a, b) => b.score - a.score).slice(0, limit);
120
+ }
121
+
122
+ async install(name: string, opts: InstallOptions = {}): Promise<InstallResult> {
123
+ if (!/^[A-Za-z][A-Za-z0-9_-]*$/.test(name)) {
124
+ throw new MarketplaceClientError(`invalid template name "${name}"`);
125
+ }
126
+ const manifest = await this.opts.registry.fetch(name);
127
+ const subdir = opts.subdir ?? manifest.name;
128
+ const filename = opts.filename ?? "crewhaus.yaml";
129
+ if (subdir.includes("..") || subdir.includes("/") || subdir.includes("\\")) {
130
+ throw new MarketplaceClientError(`invalid subdir "${subdir}"`);
131
+ }
132
+ if (filename.includes("..") || filename.includes("/") || filename.includes("\\")) {
133
+ throw new MarketplaceClientError(`invalid filename "${filename}"`);
134
+ }
135
+ const targetDir = join(this.opts.workspaceDir, subdir);
136
+ if (!existsSync(targetDir)) {
137
+ mkdirSync(targetDir, { recursive: true, mode: 0o700 });
138
+ }
139
+ const path = join(targetDir, filename);
140
+ writeFileSync(path, manifest.yaml, { mode: 0o600 });
141
+ return { path, manifest };
142
+ }
143
+ }
144
+
145
+ export type DraftPublishOptions = {
146
+ readonly registryName: string;
147
+ readonly manifest: TemplateManifest;
148
+ };
149
+
150
+ export class MarketplacePublisher {
151
+ /**
152
+ * Produce a publish-ready `PublishDraft`. The caller's git client
153
+ * (Studio's GitHub integration, or `gh` from the CLI) opens the PR
154
+ * against the canonical registry repo with `manifestJson` written
155
+ * to `templates/<name>.json`.
156
+ */
157
+ draftPublish(opts: DraftPublishOptions): PublishDraft {
158
+ const m = opts.manifest;
159
+ if (!/^[A-Za-z][A-Za-z0-9_-]*$/.test(m.name)) {
160
+ throw new MarketplaceClientError(`invalid template name "${m.name}"`);
161
+ }
162
+ if (typeof opts.registryName !== "string" || opts.registryName === "") {
163
+ throw new MarketplaceClientError("registryName is required");
164
+ }
165
+ const manifestJson = `${JSON.stringify(m, null, 2)}\n`;
166
+ const title = `Add template ${m.name} v${m.version} (${m.target})`;
167
+ const body = `# ${m.name} v${m.version}\n\n**Target:** ${m.target} \n**Author:** ${m.author}\n\n${m.description}\n\n---\n\nFiles added:\n- templates/${m.name}.json\n`;
168
+ return {
169
+ registryName: opts.registryName,
170
+ templateName: m.name,
171
+ target: m.target,
172
+ author: m.author,
173
+ description: m.description,
174
+ version: m.version,
175
+ manifestJson,
176
+ title,
177
+ body,
178
+ };
179
+ }
180
+
181
+ /**
182
+ * Convenience: writes the publish draft to disk so the caller's git
183
+ * client (or a CI bot) can pick it up. Returns the manifest path.
184
+ */
185
+ writeDraft(workspaceDir: string, draft: PublishDraft): string {
186
+ if (typeof workspaceDir !== "string" || workspaceDir === "") {
187
+ throw new MarketplaceClientError("workspaceDir is required");
188
+ }
189
+ const path = join(workspaceDir, "templates", `${draft.templateName}.json`);
190
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
191
+ writeFileSync(path, draft.manifestJson, { mode: 0o600 });
192
+ return path;
193
+ }
194
+ }
195
+
196
+ export {
197
+ MarketplaceClient as _MarketplaceClientForTest,
198
+ MarketplacePublisher as _MarketplacePublisherForTest,
199
+ };