@frockbot/plugin-bot-template 0.0.0 → 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.
@@ -0,0 +1,266 @@
1
+ // The planner: what a review card says, and what the apply would therefore do.
2
+ //
3
+ // The card is the contract. Every claim here is one the User is shown before
4
+ // they confirm, and the step list is derived from the same function, so the
5
+ // two cannot drift.
6
+ import { describe, expect, it } from "bun:test";
7
+ import type { BotTemplateV1 } from "@frockbot/template-core";
8
+ import {
9
+ describeImportPlanV1,
10
+ importedBotIdV1,
11
+ importedRoutineIdV1,
12
+ planBotTemplateImportV1,
13
+ type TemplateImportPlanInputV1,
14
+ } from "./import.ts";
15
+
16
+ const sheep = {
17
+ schemaVersion: 1 as const,
18
+ background: "meadow",
19
+ upper: "wool",
20
+ middle: "scarf",
21
+ lower: "boots",
22
+ };
23
+
24
+ function template(overrides: Partial<BotTemplateV1> = {}): BotTemplateV1 {
25
+ return {
26
+ schemaVersion: 1,
27
+ profile: {
28
+ name: "Budget",
29
+ description: "Watches the ledger.",
30
+ avatar: { kind: "sheep", recipe: sheep },
31
+ },
32
+ skills: [{ slug: "reconcile", name: "Reconcile", body: "# Reconcile" }],
33
+ routines: [
34
+ {
35
+ slug: "daily",
36
+ name: "Daily",
37
+ prompt: "Check it.",
38
+ schedule: "0 9 * * *",
39
+ timezone: "UTC",
40
+ triggerKind: "cron",
41
+ },
42
+ ],
43
+ packages: [
44
+ {
45
+ packageId: "mcp",
46
+ catalogId: "example-connector",
47
+ version: "0.0.1",
48
+ displayName: "Example",
49
+ },
50
+ ],
51
+ mcpServers: [
52
+ {
53
+ kind: "needs-connection",
54
+ name: "Beeper",
55
+ connectionTypeId: "mcp-remote-key",
56
+ hint: "Bring your own key.",
57
+ },
58
+ ],
59
+ ...overrides,
60
+ };
61
+ }
62
+
63
+ function input(
64
+ overrides: Partial<TemplateImportPlanInputV1> = {},
65
+ ): TemplateImportPlanInputV1 {
66
+ return {
67
+ importId: "import-1",
68
+ shareId: `user-a.${"a".repeat(32)}`,
69
+ hash: "b".repeat(64),
70
+ botId: "budget-abc123456789",
71
+ template: template(),
72
+ installedPackages: [],
73
+ catalogGeneration: "gen-7",
74
+ availableCatalogIds: ["example-connector"],
75
+ ...overrides,
76
+ };
77
+ }
78
+
79
+ describe("diffing against the importing User's pinned generation", () => {
80
+ it("marks a Package present in the pinned index as will-install", () => {
81
+ const plan = planBotTemplateImportV1(input());
82
+ expect(plan.packages).toEqual([
83
+ {
84
+ catalogId: "example-connector",
85
+ packageId: "mcp",
86
+ displayName: "Example",
87
+ version: "0.0.1",
88
+ status: "will-install",
89
+ },
90
+ ]);
91
+ expect(plan.steps.map((step) => step.key)).toContain(
92
+ "install:example-connector",
93
+ );
94
+ });
95
+
96
+ it("marks a Package the User already has as already-installed", () => {
97
+ const plan = planBotTemplateImportV1(
98
+ input({
99
+ installedPackages: [
100
+ {
101
+ packageId: "mcp",
102
+ state: "installed",
103
+ catalogId: "example-connector",
104
+ },
105
+ ],
106
+ }),
107
+ );
108
+ expect(plan.packages[0]!.status).toBe("already-installed");
109
+ expect(plan.steps.map((step) => step.key)).not.toContain(
110
+ "install:example-connector",
111
+ );
112
+ });
113
+
114
+ it("marks a Package absent from the pinned generation as missing", () => {
115
+ const plan = planBotTemplateImportV1(
116
+ input({ availableCatalogIds: ["something-else"] }),
117
+ );
118
+ expect(plan.packages[0]!.status).toBe("missing");
119
+ expect(
120
+ plan.steps.some((step) => step.kind === "user/install-package"),
121
+ ).toBe(false);
122
+ });
123
+
124
+ it("marks every Package missing when the User is not pinned at all", () => {
125
+ const plan = planBotTemplateImportV1(
126
+ input({
127
+ catalogGeneration: undefined,
128
+ availableCatalogIds: ["example-connector"],
129
+ }),
130
+ );
131
+ expect(plan.packages[0]!.status).toBe("missing");
132
+ expect(plan.catalogGeneration).toBeUndefined();
133
+ });
134
+
135
+ it("does not treat a failed installation as already installed", () => {
136
+ const plan = planBotTemplateImportV1(
137
+ input({
138
+ installedPackages: [
139
+ { packageId: "mcp", state: "failed", catalogId: "example-connector" },
140
+ ],
141
+ }),
142
+ );
143
+ expect(plan.packages[0]!.status).toBe("will-install");
144
+ });
145
+ });
146
+
147
+ describe("the step list", () => {
148
+ it("creates the Bot first, then installs, then Skills, then Routines", () => {
149
+ const plan = planBotTemplateImportV1(input());
150
+ expect(plan.steps.map((step) => step.kind)).toEqual([
151
+ "bot/create",
152
+ "user/install-package",
153
+ "skill/write",
154
+ "routine/create",
155
+ ]);
156
+ });
157
+
158
+ it("disables an imported webhook Routine as its own step", () => {
159
+ const plan = planBotTemplateImportV1(
160
+ input({
161
+ template: template({
162
+ routines: [
163
+ {
164
+ slug: "hook",
165
+ name: "Hook",
166
+ prompt: "go",
167
+ timezone: "UTC",
168
+ triggerKind: "webhook",
169
+ },
170
+ ],
171
+ }),
172
+ }),
173
+ );
174
+ expect(
175
+ plan.steps
176
+ .filter((step) => step.kind.startsWith("routine"))
177
+ .map((step) => step.kind),
178
+ ).toEqual(["routine/create", "routine/disable"]);
179
+ });
180
+
181
+ it("leaves a cron Routine enabled", () => {
182
+ const plan = planBotTemplateImportV1(input());
183
+ expect(plan.steps.some((step) => step.kind === "routine/disable")).toBe(
184
+ false,
185
+ );
186
+ });
187
+ });
188
+
189
+ describe("what an import never does", () => {
190
+ it("plans no Connection and no Assignment, only lines telling the User", () => {
191
+ const plan = planBotTemplateImportV1(input());
192
+ expect(plan.connections).toEqual([
193
+ {
194
+ name: "Beeper",
195
+ connectionTypeId: "mcp-remote-key",
196
+ hint: "Bring your own key.",
197
+ },
198
+ ]);
199
+ expect(plan.steps.some((step) => step.kind.includes("connection"))).toBe(
200
+ false,
201
+ );
202
+ expect(JSON.stringify(plan)).not.toContain("assignment");
203
+ });
204
+
205
+ it("lists a public server too, because no Connection is created for it", () => {
206
+ const plan = planBotTemplateImportV1(
207
+ input({
208
+ template: template({
209
+ mcpServers: [
210
+ {
211
+ kind: "public",
212
+ name: "Example",
213
+ url: "https://mcp.example.test/mcp",
214
+ transport: "streamable-http",
215
+ },
216
+ ],
217
+ }),
218
+ }),
219
+ );
220
+ expect(plan.connections[0]).toMatchObject({
221
+ name: "Example",
222
+ url: "https://mcp.example.test/mcp",
223
+ });
224
+ expect(plan.steps.some((step) => step.kind === "bot/create")).toBe(true);
225
+ });
226
+ });
227
+
228
+ describe("derived identity", () => {
229
+ it("derives the same Bot id for the same User and import", async () => {
230
+ const first = await importedBotIdV1("user-b", "import-1", "Budget");
231
+ const second = await importedBotIdV1("user-b", "import-1", "Budget");
232
+ expect(second).toBe(first);
233
+ expect(first.startsWith("budget-")).toBe(true);
234
+ });
235
+
236
+ it("derives a different Bot id for a different import", async () => {
237
+ expect(await importedBotIdV1("user-b", "import-1", "Budget")).not.toBe(
238
+ await importedBotIdV1("user-b", "import-2", "Budget"),
239
+ );
240
+ });
241
+
242
+ it("derives a different Bot id for a different User", async () => {
243
+ expect(await importedBotIdV1("user-b", "import-1", "Budget")).not.toBe(
244
+ await importedBotIdV1("user-c", "import-1", "Budget"),
245
+ );
246
+ });
247
+
248
+ it("derives a stable, safe Routine id", () => {
249
+ expect(importedRoutineIdV1("import-1", "on delivery/x")).toBe(
250
+ "import-1-on-delivery-x",
251
+ );
252
+ });
253
+ });
254
+
255
+ describe("the card's prose", () => {
256
+ it("says what will be created, installed, skipped and connected", () => {
257
+ const plan = planBotTemplateImportV1(
258
+ input({ availableCatalogIds: [], catalogGeneration: "gen-7" }),
259
+ );
260
+ const described = describeImportPlanV1(plan);
261
+ expect(described).toContain('Will create the Bot "Budget"');
262
+ expect(described).toContain("missing from your catalog");
263
+ expect(described).toContain("need your own Connection");
264
+ expect(described).toContain("No Connection and no Assignment");
265
+ });
266
+ });
package/src/import.ts ADDED
@@ -0,0 +1,292 @@
1
+ // Planning an import: the read-only half, and the step list the apply walks.
2
+ //
3
+ // NOTHING HERE APPLIES ANYTHING. Planning is a pure function of the template
4
+ // and the importing User's own durable state, which is what makes the review
5
+ // card honest: the User is shown exactly the steps the apply will take, and the
6
+ // apply takes exactly those steps.
7
+ //
8
+ // THE PINNED GENERATION IS THE ONLY INDEX CONSULTED. A `catalogId` absent from
9
+ // the generation this User is pinned to is a **missing** line, never an install
10
+ // off a moved index: "Composition consumes immutable, content-addressed
11
+ // artifacts", and an install validated against anything else is not that.
12
+ //
13
+ // WHAT IMPORT NEVER CREATES. No Connection and no Assignment. "Package
14
+ // availability is User-level. A Bot receives authority solely through an
15
+ // explicit, durable Assignment and, when required, a Connection." A template is
16
+ // a recipe; granting authority off the back of one would be the recipe handing
17
+ // itself permissions. Every server the template names becomes a line on the
18
+ // card telling the User what they would have to connect themselves.
19
+ import type {
20
+ BotTemplateV1,
21
+ TemplateSheepRecipeV1,
22
+ TemplateSkillV1,
23
+ TemplateRoutineV1,
24
+ } from "@frockbot/template-core";
25
+ import { TemplateDecodeError } from "@frockbot/template-core";
26
+
27
+ export type TemplateImportPackageStatusV1 =
28
+ "will-install" | "already-installed" | "missing";
29
+
30
+ export interface TemplateImportPackageLineV1 {
31
+ catalogId: string;
32
+ packageId: string;
33
+ displayName: string;
34
+ version: string;
35
+ status: TemplateImportPackageStatusV1;
36
+ }
37
+
38
+ /** One server the importer would have to connect themselves. */
39
+ export interface TemplateImportConnectionLineV1 {
40
+ name: string;
41
+ connectionTypeId?: string;
42
+ /** Present only for a public server; a placeholder carries no address. */
43
+ url?: string;
44
+ hint?: string;
45
+ }
46
+
47
+ export type TemplateImportStepKindV1 =
48
+ | "bot/create"
49
+ | "user/install-package"
50
+ | "skill/write"
51
+ | "routine/create"
52
+ | "routine/disable";
53
+
54
+ export interface TemplateImportStepV1 {
55
+ /** Stable across replays: it is what a receipt is filed under. */
56
+ key: string;
57
+ kind: TemplateImportStepKindV1;
58
+ /** The `catalogId`, Skill slug or Routine slug this step acts on. */
59
+ subject?: string;
60
+ }
61
+
62
+ export interface TemplateImportPlanV1 {
63
+ schemaVersion: 1;
64
+ importId: string;
65
+ shareId: string;
66
+ hash: string;
67
+ /** The Bot this import would create. Derived, so a replay asks for the same. */
68
+ botId: string;
69
+ profile: { name: string; title?: string; description?: string };
70
+ sheep: TemplateSheepRecipeV1;
71
+ skills: TemplateSkillV1[];
72
+ routines: TemplateRoutineV1[];
73
+ packages: TemplateImportPackageLineV1[];
74
+ connections: TemplateImportConnectionLineV1[];
75
+ /** The generation the plan was diffed against; absent when unpinned. */
76
+ catalogGeneration?: string;
77
+ steps: TemplateImportStepV1[];
78
+ }
79
+
80
+ /** One installed Package, as the importing User's settings record it. */
81
+ export interface ImportingInstallationV1 {
82
+ packageId: string;
83
+ state: "installed" | "disabled" | "failed";
84
+ catalogId?: string;
85
+ }
86
+
87
+ export interface TemplateImportPlanInputV1 {
88
+ importId: string;
89
+ shareId: string;
90
+ hash: string;
91
+ botId: string;
92
+ template: BotTemplateV1;
93
+ installedPackages: readonly ImportingInstallationV1[];
94
+ /** The importing User's own pin. Absent leaves every Package `missing`. */
95
+ catalogGeneration?: string;
96
+ /** Every `catalogId` the pinned generation's index holds. */
97
+ availableCatalogIds: readonly string[];
98
+ }
99
+
100
+ function packageLines(
101
+ input: TemplateImportPlanInputV1,
102
+ ): TemplateImportPackageLineV1[] {
103
+ const installed = new Set(
104
+ input.installedPackages
105
+ .filter((entry) => entry.state !== "failed" && entry.catalogId)
106
+ .map((entry) => entry.catalogId as string),
107
+ );
108
+ const available = new Set(input.availableCatalogIds);
109
+ return input.template.packages.map((entry) => ({
110
+ catalogId: entry.catalogId,
111
+ packageId: entry.packageId,
112
+ displayName: entry.displayName,
113
+ version: entry.version,
114
+ status: installed.has(entry.catalogId)
115
+ ? ("already-installed" as const)
116
+ : input.catalogGeneration && available.has(entry.catalogId)
117
+ ? ("will-install" as const)
118
+ : // Not in the generation this User is pinned to. It is reported as a
119
+ // gap the User can close, never installed off an index that moved.
120
+ ("missing" as const),
121
+ }));
122
+ }
123
+
124
+ function connectionLines(
125
+ template: BotTemplateV1,
126
+ ): TemplateImportConnectionLineV1[] {
127
+ // Every server is a line, public ones included: the import creates no
128
+ // Connection at all, so even a server whose address travelled is still
129
+ // something the importing User has to connect for themselves.
130
+ return template.mcpServers.map((server) =>
131
+ server.kind === "public"
132
+ ? {
133
+ name: server.name,
134
+ url: server.url,
135
+ hint: "Add this server as your own Connection to use it.",
136
+ }
137
+ : {
138
+ name: server.name,
139
+ connectionTypeId: server.connectionTypeId,
140
+ ...(server.hint === undefined ? {} : { hint: server.hint }),
141
+ },
142
+ );
143
+ }
144
+
145
+ /**
146
+ * The steps one apply will take, in order.
147
+ *
148
+ * The Bot first, because everything else is written into it; installs next, so
149
+ * a Skill that leans on a Package finds it there; then Skills and Routines. A
150
+ * webhook Routine is created and then disabled, because a Routine is created
151
+ * enabled and a webhook one has no key in this deployment yet — an imported
152
+ * Routine that fired on a stranger's schedule with no key would be a surprise,
153
+ * not a feature.
154
+ */
155
+ function importSteps(
156
+ plan: Omit<TemplateImportPlanV1, "steps">,
157
+ packages: TemplateImportPackageLineV1[],
158
+ ): TemplateImportStepV1[] {
159
+ return [
160
+ { key: "bot/create", kind: "bot/create" as const },
161
+ ...packages
162
+ .filter((entry) => entry.status === "will-install")
163
+ .map((entry) => ({
164
+ key: `install:${entry.catalogId}`,
165
+ kind: "user/install-package" as const,
166
+ subject: entry.catalogId,
167
+ })),
168
+ ...plan.skills.map((skill) => ({
169
+ key: `skill:${skill.slug}`,
170
+ kind: "skill/write" as const,
171
+ subject: skill.slug,
172
+ })),
173
+ ...plan.routines.flatMap((routine) => [
174
+ {
175
+ key: `routine:${routine.slug}`,
176
+ kind: "routine/create" as const,
177
+ subject: routine.slug,
178
+ },
179
+ ...(routine.triggerKind === "webhook"
180
+ ? [
181
+ {
182
+ key: `routine-disable:${routine.slug}`,
183
+ kind: "routine/disable" as const,
184
+ subject: routine.slug,
185
+ },
186
+ ]
187
+ : []),
188
+ ]),
189
+ ];
190
+ }
191
+
192
+ export function planBotTemplateImportV1(
193
+ input: TemplateImportPlanInputV1,
194
+ ): TemplateImportPlanV1 {
195
+ const packages = packageLines(input);
196
+ const base: Omit<TemplateImportPlanV1, "steps"> = {
197
+ schemaVersion: 1,
198
+ importId: input.importId,
199
+ shareId: input.shareId,
200
+ hash: input.hash,
201
+ botId: input.botId,
202
+ profile: {
203
+ name: input.template.profile.name,
204
+ ...(input.template.profile.title === undefined
205
+ ? {}
206
+ : { title: input.template.profile.title }),
207
+ ...(input.template.profile.description === undefined
208
+ ? {}
209
+ : { description: input.template.profile.description }),
210
+ },
211
+ sheep: input.template.profile.avatar.recipe,
212
+ skills: input.template.skills,
213
+ routines: input.template.routines,
214
+ packages,
215
+ connections: connectionLines(input.template),
216
+ ...(input.catalogGeneration === undefined
217
+ ? {}
218
+ : { catalogGeneration: input.catalogGeneration }),
219
+ };
220
+ return { ...base, steps: importSteps(base, packages) };
221
+ }
222
+
223
+ /**
224
+ * The Bot id one import asks for.
225
+ *
226
+ * Derived from the importing User and the import's own id, so a replay after
227
+ * eviction asks for the *same* Bot and collides with the one it already made
228
+ * rather than registering a second. Exactly the fence `plugin-flock`'s
229
+ * `bot_create` uses, for exactly the same reason. The readable half is the
230
+ * template's name, because names become roles.
231
+ */
232
+ export async function importedBotIdV1(
233
+ userId: string,
234
+ importId: string,
235
+ name: string,
236
+ ): Promise<string> {
237
+ const base =
238
+ name
239
+ .toLowerCase()
240
+ .normalize("NFKD")
241
+ .replace(/[^a-z0-9]+/g, "-")
242
+ .replace(/^-|-$/g, "")
243
+ .slice(0, 80) || "bot";
244
+ const digest = await crypto.subtle.digest(
245
+ "SHA-256",
246
+ new TextEncoder().encode(`${userId} ${importId}`),
247
+ );
248
+ const hex = [...new Uint8Array(digest)]
249
+ .map((byte) => byte.toString(16).padStart(2, "0"))
250
+ .join("");
251
+ return `${base}-${hex.slice(0, 12)}`;
252
+ }
253
+
254
+ /** The Routine id an imported Routine takes, stable across replays. */
255
+ export function importedRoutineIdV1(importId: string, slug: string): string {
256
+ const id = `${importId}-${slug}`.replace(/[^a-zA-Z0-9._:-]/g, "-");
257
+ return id.slice(0, 120);
258
+ }
259
+
260
+ /** One line of the review card, as prose. */
261
+ export function describeImportPlanV1(plan: TemplateImportPlanV1): string {
262
+ const missing = plan.packages.filter(
263
+ (entry) => entry.status === "missing",
264
+ ).length;
265
+ const installing = plan.packages.filter(
266
+ (entry) => entry.status === "will-install",
267
+ ).length;
268
+ return [
269
+ `Will create the Bot "${plan.profile.name}" with ${plan.skills.length} Skill(s) and ${plan.routines.length} Routine(s).`,
270
+ installing > 0 ? `Will install ${installing} Package(s).` : "",
271
+ missing > 0
272
+ ? `${missing} Package(s) are missing from your catalog and will be skipped.`
273
+ : "",
274
+ plan.connections.length > 0
275
+ ? `${plan.connections.length} server(s) need your own Connection; none is created for you.`
276
+ : "",
277
+ "No Connection and no Assignment is created by an import.",
278
+ ]
279
+ .filter(Boolean)
280
+ .join(" ");
281
+ }
282
+
283
+ export function assertImportPlanMatchesV1(
284
+ plan: TemplateImportPlanV1,
285
+ expected: { shareId: string; hash: string },
286
+ ): void {
287
+ if (plan.shareId !== expected.shareId || plan.hash !== expected.hash) {
288
+ throw new TemplateDecodeError(
289
+ "the import plan does not match the template it was planned from",
290
+ );
291
+ }
292
+ }
package/src/index.ts ADDED
@@ -0,0 +1,9 @@
1
+ export * from "./shared.js";
2
+ export * from "./scrub.js";
3
+ export {
4
+ createBotTemplateBackendContribution,
5
+ type BotTemplateBackendRouteContribution,
6
+ type BotTemplateGatewayHostV1,
7
+ type PublishedTemplateV1,
8
+ } from "./backend.js";
9
+ export { default as manifest } from "./manifest.js";
@@ -0,0 +1,3 @@
1
+ import manifest from "../frockbot.json" with { type: "json" };
2
+
3
+ export default manifest;