@crewhaus/template-marketplace-client 0.1.3 → 0.1.5

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