wizards 0.2.2 → 0.2.3

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.
Files changed (75) hide show
  1. package/apps/runtime/dist/app.js +44 -0
  2. package/apps/runtime/dist/auth/account.js +16 -2
  3. package/apps/runtime/dist/auth/index.js +14 -1
  4. package/apps/runtime/dist/connectors/external.js +5 -0
  5. package/apps/runtime/dist/db/control-schema.js +12 -3
  6. package/apps/runtime/dist/db/migrations/control/0005_chilly_oracle.sql +1 -0
  7. package/apps/runtime/dist/db/migrations/control/0006_sleepy_mojo.sql +1 -0
  8. package/apps/runtime/dist/db/migrations/control/meta/0005_snapshot.json +358 -0
  9. package/apps/runtime/dist/db/migrations/control/meta/0006_snapshot.json +365 -0
  10. package/apps/runtime/dist/db/migrations/control/meta/_journal.json +14 -0
  11. package/apps/runtime/dist/db/migrations/tenant/0005_reflective_forgotten_one.sql +2 -0
  12. package/apps/runtime/dist/db/migrations/tenant/meta/0005_snapshot.json +1659 -0
  13. package/apps/runtime/dist/db/migrations/tenant/meta/_journal.json +7 -0
  14. package/apps/runtime/dist/db/schema.js +6 -0
  15. package/apps/runtime/dist/engine/asks.js +2 -0
  16. package/apps/runtime/dist/engine/push.js +58 -0
  17. package/apps/runtime/dist/engine/runner.js +6 -0
  18. package/apps/runtime/dist/env.js +13 -0
  19. package/apps/runtime/dist/limits.js +21 -0
  20. package/apps/runtime/dist/manage.js +16 -3
  21. package/apps/runtime/dist/mcp/tools.js +4 -2
  22. package/apps/runtime/dist/routes/api.js +96 -6
  23. package/apps/runtime/dist/routes/runs.js +68 -2
  24. package/apps/runtime/dist/routes/studio.js +79 -11
  25. package/apps/runtime/dist/services/access.js +62 -0
  26. package/apps/runtime/dist/services/architect.js +3 -2
  27. package/apps/runtime/dist/services/cloud.js +241 -39
  28. package/apps/runtime/dist/services/errors.js +1 -0
  29. package/apps/runtime/dist/services/files.js +3 -3
  30. package/apps/runtime/dist/services/project-files.js +6 -0
  31. package/apps/runtime/dist/services/projects.js +38 -14
  32. package/apps/runtime/dist/services/spaces.js +400 -0
  33. package/apps/runtime/dist/services/wizards.js +19 -8
  34. package/apps/runtime/dist/tenants/control.js +47 -0
  35. package/apps/web/dist/assets/{ChangelogDialog-4HCt88rf.js → ChangelogDialog-CbZBUMRu.js} +1 -1
  36. package/apps/web/dist/assets/ChatPanel-zurtfj6A.js +1 -0
  37. package/apps/web/dist/assets/{CreditsDialog-D3fZRqXH.js → CreditsDialog-BewZHZTR.js} +1 -1
  38. package/apps/web/dist/assets/EditorPage-B7ZOoW5h.js +2 -0
  39. package/apps/web/dist/assets/{FlowPreview-CjUK5046.js → FlowPreview-DHrhPps0.js} +1 -1
  40. package/apps/web/dist/assets/HomePage-CBvWxXsq.js +1 -0
  41. package/apps/web/dist/assets/ImportWizard-CKjVJMaG.js +1 -0
  42. package/apps/web/dist/assets/{LocalRuntime-DupxjXff.js → LocalRuntime-Brkvbzzg.js} +10 -10
  43. package/apps/web/dist/assets/NewWizardPage-B3XePMlp.js +2 -0
  44. package/apps/web/dist/assets/PublicRunner-Bn1g6HG9.js +1 -0
  45. package/apps/web/dist/assets/RunnerView-CO1Nk3jn.js +2 -0
  46. package/apps/web/dist/assets/SettingsPage-BIY4kHZl.js +2 -0
  47. package/apps/web/dist/assets/{SetupPage-xiqJMR0J.js → SetupPage-BvLwZH9i.js} +1 -1
  48. package/apps/web/dist/assets/{SharePage-C8B9BUSU.js → SharePage-CDESJ11B.js} +1 -1
  49. package/apps/web/dist/assets/{api-KMi35npD.js → api-9sHuNTkp.js} +1 -1
  50. package/apps/web/dist/assets/changelog-Cto0j5-V.js +1 -0
  51. package/apps/web/dist/assets/index-CIwJmOjo.css +1 -0
  52. package/apps/web/dist/assets/{index-2Ktgo6f1.js → index-VsRgvimv.js} +3 -3
  53. package/apps/web/dist/assets/{meta-4R1Lnr25.js → meta-B4mZqEso.js} +1 -1
  54. package/apps/web/dist/assets/oss-credits-Hu4LieLA.js +1 -0
  55. package/apps/web/dist/assets/outputs-boPx5ngv.js +69 -0
  56. package/apps/web/dist/assets/{session-B6-6VomQ.js → session-CJGuo1Tv.js} +1 -1
  57. package/apps/web/dist/assets/type-BMJpS6_D.js +1 -0
  58. package/apps/web/dist/assets/ui-iTYHqwvY.js +1 -0
  59. package/apps/web/dist/index.html +5 -5
  60. package/apps/web/dist/mcp/flow.html +54 -54
  61. package/package.json +1 -1
  62. package/apps/web/dist/assets/ChatPanel-B91fTrzg.js +0 -1
  63. package/apps/web/dist/assets/EditorPage-ZZjb9mGV.js +0 -2
  64. package/apps/web/dist/assets/HomePage-Cn5TCZk7.js +0 -1
  65. package/apps/web/dist/assets/ImportWizard-DiV62Uno.js +0 -1
  66. package/apps/web/dist/assets/NewWizardPage-Bz3PIQGp.js +0 -2
  67. package/apps/web/dist/assets/PublicRunner-Axs1zwqe.js +0 -1
  68. package/apps/web/dist/assets/RunnerView-Bhv6RNME.js +0 -2
  69. package/apps/web/dist/assets/SettingsPage-in_RmI8i.js +0 -2
  70. package/apps/web/dist/assets/changelog-C2LDEzLQ.js +0 -1
  71. package/apps/web/dist/assets/index-B9YVjvVY.css +0 -1
  72. package/apps/web/dist/assets/oss-credits-roB62QJu.js +0 -1
  73. package/apps/web/dist/assets/outputs-Dhqtf-SA.js +0 -69
  74. package/apps/web/dist/assets/type-Bj9H3eOK.js +0 -1
  75. package/apps/web/dist/assets/ui-Cs6lpsmj.js +0 -1
@@ -1,11 +1,12 @@
1
1
  import { FACT_TYPES, PROJECT_LIMITS } from "@engenty-wizards/shared/projects";
2
- import { and, count, eq } from "drizzle-orm";
2
+ import { and, count, eq, isNull } from "drizzle-orm";
3
3
  import { nanoid } from "nanoid";
4
4
  import { z } from "zod";
5
5
  import { db, schema } from "../db/client.js";
6
6
  import { env } from "../env.js";
7
7
  import { managed, tenantInfo } from "../manage.js";
8
8
  import { currentTenant } from "../tenants/tenant.js";
9
+ import { mayBuild, requireBuild, requireWritable } from "./access.js";
9
10
  import { notFound, ServiceError } from "./errors.js";
10
11
  import { forgetWizardLinks } from "./links.js";
11
12
  import { removeProjectFiles } from "./project-files.js";
@@ -61,22 +62,33 @@ export async function ownedProject(_userId, projectId) {
61
62
  }
62
63
  return p;
63
64
  }
64
- /** A tenant always has a project: the first one is made when its studio is first opened. */
65
+ /** The tenant's projects that were made here, not synced from a local install. */
66
+ const madeHere = () => and(eq(schema.project.tenantId, currentTenant()), isNull(schema.project.origin));
67
+ /** The tenant's projects a local install synced. */
68
+ const synced = () => and(eq(schema.project.tenantId, currentTenant()), eq(schema.project.origin, "local"));
69
+ /**
70
+ * A tenant that builds here always has a project of its own: the first one is made when its
71
+ * studio is first opened.
72
+ */
65
73
  async function ensureProject() {
66
- const any = await db.query.project.findFirst({
67
- where: eq(schema.project.tenantId, currentTenant()),
68
- });
74
+ const any = await db.query.project.findFirst({ where: madeHere() });
69
75
  if (!any) {
70
76
  await db
71
77
  .insert(schema.project)
72
78
  .values({ id: nanoid(12), tenantId: currentTenant(), name: "Meine Wizards" });
73
79
  }
74
80
  }
75
- /** The project a wizard lands in when the caller names none: the oldest one. */
81
+ /**
82
+ * The project a caller means when it names none: the oldest one made here, where a wizard
83
+ * lands; for a tenant that builds nothing here, the one its local install synced.
84
+ */
76
85
  export async function defaultProject(_userId) {
77
- await ensureProject();
86
+ const build = await mayBuild();
87
+ if (build) {
88
+ await ensureProject();
89
+ }
78
90
  const p = await db.query.project.findFirst({
79
- where: eq(schema.project.tenantId, currentTenant()),
91
+ where: build ? madeHere() : synced(),
80
92
  orderBy: [schema.project.createdAt],
81
93
  });
82
94
  if (!p) {
@@ -84,11 +96,18 @@ export async function defaultProject(_userId) {
84
96
  }
85
97
  return p;
86
98
  }
99
+ /**
100
+ * The projects the studio shows. A tenant that builds here: its own and the synced ones, up to
101
+ * its number. One that does not: only what its local install synced — possibly nothing yet.
102
+ */
87
103
  export async function listProjects(_userId) {
88
- await ensureProject();
104
+ const build = await mayBuild();
105
+ if (build) {
106
+ await ensureProject();
107
+ }
89
108
  // The oldest come first: with a limit of one, that is the project everything lands in.
90
109
  const projects = await db.query.project.findMany({
91
- where: eq(schema.project.tenantId, currentTenant()),
110
+ where: build ? eq(schema.project.tenantId, currentTenant()) : synced(),
92
111
  orderBy: [schema.project.createdAt],
93
112
  limit: await projectLimit(),
94
113
  });
@@ -112,6 +131,7 @@ export function maskedServers(p) {
112
131
  }));
113
132
  }
114
133
  export async function createProject(_userId, name) {
134
+ await requireBuild();
115
135
  const [{ n }] = await db
116
136
  .select({ n: count() })
117
137
  .from(schema.project)
@@ -128,6 +148,7 @@ export async function createProject(_userId, name) {
128
148
  }
129
149
  export async function updateProject(userId, projectId, patch) {
130
150
  const p = await ownedProject(userId, projectId);
151
+ await requireWritable(p);
131
152
  const mcpServers = patch.mcpServers?.map((s) => {
132
153
  const before = p.mcpServers.find((x) => x.id === s.id);
133
154
  const headers = s.headers
@@ -160,13 +181,16 @@ export function projectProfile(p) {
160
181
  facts: p.facts.filter((f) => f.value.trim()),
161
182
  };
162
183
  }
163
- export async function deleteProject(_userId, projectId) {
164
- const all = await db.query.project.findMany({
165
- where: eq(schema.project.tenantId, currentTenant()),
166
- });
184
+ export async function deleteProject(userId, projectId) {
185
+ await requireWritable(await ownedProject(userId, projectId));
186
+ const all = await db.query.project.findMany({ where: madeHere() });
167
187
  if (all.length <= 1) {
168
188
  throw new ServiceError("refused", "Das letzte Projekt bleibt.");
169
189
  }
190
+ await removeProject(projectId);
191
+ }
192
+ /** Takes a project away with its wizards' links and its files. */
193
+ export async function removeProject(projectId) {
170
194
  const wizards = await db
171
195
  .select({ id: schema.wizard.id })
172
196
  .from(schema.wizard)
@@ -0,0 +1,400 @@
1
+ import { createHash } from "node:crypto";
2
+ import { PROJECT_LIMITS } from "@engenty-wizards/shared/projects";
3
+ import { WORKSPACE_LIMITS } from "@engenty-wizards/shared/workspace";
4
+ import { and, count, eq, inArray, sql } from "drizzle-orm";
5
+ import { nanoid } from "nanoid";
6
+ import { z } from "zod";
7
+ import { db, schema } from "../db/client.js";
8
+ import { missingModels, unavoidableSteps } from "../engine/requirements.js";
9
+ import { env } from "../env.js";
10
+ import { codeOf, putLink } from "../tenants/control.js";
11
+ import { currentTenant } from "../tenants/tenant.js";
12
+ import { asSync, mayBuild } from "./access.js";
13
+ import { emitDraftChanged } from "./draft-events.js";
14
+ import { notFound, ServiceError } from "./errors.js";
15
+ import { draftFiles, writeFile } from "./files.js";
16
+ import { addProjectFile, projectFiles, removeProjectFile } from "./project-files.js";
17
+ import { brandColorSchema, projectFactSchema, projectLimit, removeProject, } from "./projects.js";
18
+ import { deleteWizard, draftIssues, parseDraft, shareUrl } from "./wizards.js";
19
+ /**
20
+ * A local install's project on this runtime (docs/manage-contract.md, "Spaces of a local
21
+ * install"). The install sends a wizard when it publishes it: the published version and its
22
+ * workspace, under the ids they have there. Here the project is shown and its wizards run; it
23
+ * is changed only by the next sync. The share link is this runtime's own.
24
+ */
25
+ /** Ids as the local install makes them (nanoid); nothing else is taken as a key. */
26
+ const syncedId = z.string().regex(/^[A-Za-z0-9_-]{8,40}$/);
27
+ const spaceSchema = z.object({
28
+ name: z.string().min(1).max(80),
29
+ brand: z
30
+ .object({
31
+ name: z.string().max(120).optional(),
32
+ about: z.string().max(8000).optional(),
33
+ colors: z.array(brandColorSchema).max(PROJECT_LIMITS.colors).optional(),
34
+ })
35
+ .default({}),
36
+ facts: z.array(projectFactSchema).max(PROJECT_LIMITS.facts).default([]),
37
+ /** The logo end users see; null for none. */
38
+ logo: z
39
+ .object({
40
+ name: z.string().min(1).max(120),
41
+ mime: z.string().max(100),
42
+ description: z.string().max(600).default(""),
43
+ data: z.string(),
44
+ })
45
+ .nullable()
46
+ .default(null),
47
+ });
48
+ export const syncWizardSchema = z.object({
49
+ space: spaceSchema,
50
+ /** The version the install published, as it counts them. */
51
+ version: z.number().int().min(1).max(1_000_000),
52
+ definition: z.unknown(),
53
+ files: z
54
+ .array(z.object({
55
+ path: z.string().max(300),
56
+ mime: z.string().max(100).optional(),
57
+ data: z.string(),
58
+ }))
59
+ .max(WORKSPACE_LIMITS.files)
60
+ .default([]),
61
+ shareEnabled: z.boolean().default(true),
62
+ dailyRunLimit: z.number().int().min(1).max(10_000).optional(),
63
+ });
64
+ export const syncSettingsSchema = z.object({
65
+ shareEnabled: z.boolean().optional(),
66
+ dailyRunLimit: z.number().int().min(1).max(10_000).optional(),
67
+ });
68
+ export const checkSchema = z.object({
69
+ definition: z.unknown(),
70
+ /** Paths of the wizard's workspace files. */
71
+ files: z.array(z.string().max(300)).max(WORKSPACE_LIMITS.files).default([]),
72
+ });
73
+ const stepsWith = (def, has) => def.steps.filter(has).map((s) => ({ id: s.id, title: s.title }));
74
+ /**
75
+ * What of a wizard does not work on this runtime: what its validator says here (a connector
76
+ * the project lacks, a plugin's tool), model classes nothing is bound to, the sandbox where
77
+ * there is none, MCP servers the project does not know.
78
+ */
79
+ export async function serverProblems(def, issues, mcpServers) {
80
+ const problems = issues.map((issue) => ({
81
+ code: /connector/i.test(issue.message) ? "connector" : "invalid",
82
+ blocking: true,
83
+ steps: stepsWith(def, (s) => s.id === issue.stepId),
84
+ detail: issue.message,
85
+ }));
86
+ for (const missing of await missingModels(def)) {
87
+ problems.push({
88
+ code: "model",
89
+ blocking: missing.blocking,
90
+ steps: missing.steps,
91
+ detail: `${missing.cls}: ${missing.problem}`,
92
+ });
93
+ }
94
+ if (env.sandbox === "off") {
95
+ const steps = stepsWith(def, (s) => s.type === "agent" && s.tools.includes("sandbox"));
96
+ if (steps.length) {
97
+ problems.push({ code: "sandbox", blocking: false, steps, detail: "" });
98
+ }
99
+ }
100
+ const unknown = new Set();
101
+ const steps = stepsWith(def, (s) => {
102
+ const lacking = s.type === "agent" ? (s.mcp ?? []).filter((id) => !mcpServers.includes(id)) : [];
103
+ for (const id of lacking) {
104
+ unknown.add(id);
105
+ }
106
+ return lacking.length > 0;
107
+ });
108
+ if (steps.length) {
109
+ const unavoidable = unavoidableSteps(def);
110
+ problems.push({
111
+ code: "mcp",
112
+ blocking: steps.some((s) => unavoidable.has(s.id)),
113
+ steps,
114
+ detail: [...unknown].join(", "),
115
+ });
116
+ }
117
+ return problems;
118
+ }
119
+ /** What a wizard would lack here, asked before it is sent: no project is touched. */
120
+ export async function checkOnServer(input) {
121
+ const { draft, issues } = parseDraft(input.definition, input.files);
122
+ // Connectors are a project's; a project that is not here yet has none.
123
+ const connectors = (draft.connections ?? [])
124
+ .filter((c) => c.connector)
125
+ .map((c) => ({
126
+ message: `Connection "${c.id}" uses connector "${c.connector}", which this project has not imported.`,
127
+ }));
128
+ return serverProblems(draft, [...issues, ...connectors], []);
129
+ }
130
+ async function syncedProject(spaceId) {
131
+ const p = await db.query.project.findFirst({
132
+ where: and(eq(schema.project.id, spaceId), eq(schema.project.tenantId, currentTenant())),
133
+ });
134
+ return p ?? null;
135
+ }
136
+ /** Refuses a project beyond the tenant's number; a synced one counts like one made here. */
137
+ async function requireRoom(spaceId) {
138
+ const mine = eq(schema.project.tenantId, currentTenant());
139
+ // What a tenant that builds nothing here once made here is not shown, so it takes no place.
140
+ const counted = (await mayBuild()) ? mine : and(mine, eq(schema.project.origin, "local"));
141
+ const others = await db
142
+ .select({ id: schema.project.id, name: schema.project.name, origin: schema.project.origin })
143
+ .from(schema.project)
144
+ .where(and(counted, sql `${schema.project.id} <> ${spaceId}`));
145
+ const limit = await projectLimit();
146
+ if (others.length >= limit) {
147
+ throw new ServiceError("refused", limit === 1
148
+ ? "Dieses Konto hat hier schon ein Projekt."
149
+ : `Dieses Konto hat hier schon ${limit} Projekte.`, {
150
+ reason: "space_limit",
151
+ limit,
152
+ spaces: others.filter((p) => p.origin === "local").map((p) => ({ id: p.id, name: p.name })),
153
+ });
154
+ }
155
+ }
156
+ /** A synced project holds so many wizards: each brings up to 25 MB of files to keep. */
157
+ async function requireWizardRoom(spaceId) {
158
+ const [row] = await db
159
+ .select({ n: count() })
160
+ .from(schema.wizard)
161
+ .where(eq(schema.wizard.projectId, spaceId));
162
+ if ((row?.n ?? 0) >= env.limits.syncedWizards) {
163
+ throw new ServiceError("refused", `Hier haben höchstens ${env.limits.syncedWizards} veröffentlichte Wizards Platz.`, { reason: "wizard_limit", limit: env.limits.syncedWizards });
164
+ }
165
+ }
166
+ const hashOf = (data) => createHash("sha256").update(data).digest("hex");
167
+ /** The project's logo follows the install's: kept when it is the same picture, else replaced. */
168
+ async function syncLogo(projectId, logo) {
169
+ const here = await projectFiles(projectId, "logo");
170
+ const data = logo ? Buffer.from(logo.data, "base64") : null;
171
+ const mark = data ? `sync:${hashOf(data)}` : null;
172
+ if (mark && here.length === 1 && here[0].source === mark) {
173
+ return;
174
+ }
175
+ for (const old of here) {
176
+ await removeProjectFile(projectId, old.id);
177
+ }
178
+ if (logo && data && mark) {
179
+ await addProjectFile(projectId, {
180
+ kind: "logo",
181
+ name: logo.name,
182
+ mime: logo.mime,
183
+ data,
184
+ // With words of its own no model is asked to describe it.
185
+ description: logo.description || logo.name,
186
+ source: mark,
187
+ });
188
+ }
189
+ }
190
+ /** Makes or updates the synced project: its name, what it says about itself, its logo. */
191
+ async function syncSpace(spaceId, space) {
192
+ const existing = await syncedProject(spaceId);
193
+ if (existing && existing.origin !== "local") {
194
+ throw new ServiceError("refused", "Ein Projekt mit dieser Kennung wurde hier angelegt.", {
195
+ reason: "id_taken",
196
+ });
197
+ }
198
+ if (!existing) {
199
+ await requireRoom(spaceId);
200
+ }
201
+ const now = new Date();
202
+ const values = { name: space.name, brand: space.brand, facts: space.facts, syncedAt: now };
203
+ if (existing) {
204
+ await db
205
+ .update(schema.project)
206
+ .set({ ...values, updatedAt: now })
207
+ .where(eq(schema.project.id, spaceId));
208
+ }
209
+ else {
210
+ await db.insert(schema.project).values({
211
+ id: spaceId,
212
+ tenantId: currentTenant(),
213
+ origin: "local",
214
+ mcpServers: [],
215
+ ...values,
216
+ });
217
+ }
218
+ await syncLogo(spaceId, space.logo);
219
+ const project = await syncedProject(spaceId);
220
+ if (!project) {
221
+ throw notFound();
222
+ }
223
+ return project;
224
+ }
225
+ /**
226
+ * Takes a wizard a local install published: the project it is in, the wizard under its id, its
227
+ * workspace, and the version — published here when a run of it can finish. Sending the same
228
+ * wizard again updates the copy; its link stays.
229
+ */
230
+ export async function syncWizard(userId, rawSpaceId, rawWizardId, input) {
231
+ const spaceId = syncedId.parse(rawSpaceId);
232
+ const wizardId = syncedId.parse(rawWizardId);
233
+ const paths = input.files.map((f) => f.path);
234
+ // The shape is checked before anything is written; a wrong one leaves nothing behind.
235
+ const { draft } = parseDraft(input.definition, paths);
236
+ return asSync(async () => {
237
+ const project = await syncSpace(spaceId, input.space);
238
+ const existing = await db.query.wizard.findFirst({
239
+ where: and(eq(schema.wizard.id, wizardId), eq(schema.wizard.tenantId, currentTenant())),
240
+ });
241
+ if (existing && existing.projectId !== spaceId) {
242
+ throw new ServiceError("refused", "Ein Wizard mit dieser Kennung gehört hier woanders hin.", {
243
+ reason: "id_taken",
244
+ });
245
+ }
246
+ const settings = {
247
+ title: draft.title,
248
+ draft,
249
+ shareEnabled: input.shareEnabled,
250
+ ...(input.dailyRunLimit ? { dailyRunLimit: input.dailyRunLimit } : {}),
251
+ };
252
+ if (!existing) {
253
+ await requireWizardRoom(spaceId);
254
+ }
255
+ let shareToken = existing?.shareToken;
256
+ if (existing) {
257
+ await db
258
+ .update(schema.wizard)
259
+ .set({ ...settings, revision: sql `${schema.wizard.revision} + 1`, updatedAt: new Date() })
260
+ .where(eq(schema.wizard.id, wizardId));
261
+ }
262
+ else {
263
+ // The link is made here: a token an install brought could be one another tenant holds.
264
+ shareToken = nanoid(14);
265
+ await db.insert(schema.wizard).values({
266
+ id: wizardId,
267
+ projectId: spaceId,
268
+ tenantId: currentTenant(),
269
+ shareToken,
270
+ dailyRunLimit: env.limits.defaultDailyRuns,
271
+ ...settings,
272
+ });
273
+ await putLink(shareToken, "wizard", wizardId);
274
+ }
275
+ // The workspace becomes what was sent: files that are gone there go here.
276
+ const before = await draftFiles(wizardId);
277
+ const gone = before.filter((f) => !paths.includes(f.path)).map((f) => f.path);
278
+ if (gone.length) {
279
+ await db
280
+ .delete(schema.wizardFile)
281
+ .where(and(eq(schema.wizardFile.wizardId, wizardId), inArray(schema.wizardFile.path, gone)));
282
+ }
283
+ for (const file of input.files) {
284
+ await writeFile(userId, wizardId, file.path, Buffer.from(file.data, "base64"), file.mime);
285
+ }
286
+ const row = await db.query.wizard.findFirst({ where: eq(schema.wizard.id, wizardId) });
287
+ if (!row) {
288
+ throw notFound();
289
+ }
290
+ const problems = await serverProblems(draft, await draftIssues(row), project.mcpServers.map((s) => s.id));
291
+ const runnable = !problems.some((p) => p.blocking);
292
+ let publishedVersion = row.publishedVersion;
293
+ if (runnable) {
294
+ const files = await draftFiles(wizardId);
295
+ await db
296
+ .insert(schema.wizardVersion)
297
+ .values({ id: nanoid(12), wizardId, version: input.version, definition: draft, files })
298
+ .onConflictDoUpdate({
299
+ target: [schema.wizardVersion.wizardId, schema.wizardVersion.version],
300
+ set: { definition: draft, files },
301
+ });
302
+ await db
303
+ .update(schema.wizard)
304
+ .set({ publishedVersion: input.version })
305
+ .where(eq(schema.wizard.id, wizardId));
306
+ publishedVersion = input.version;
307
+ }
308
+ emitDraftChanged({
309
+ wizardId,
310
+ kind: "draft",
311
+ revision: row.revision,
312
+ source: "mcp",
313
+ client: "sync",
314
+ note: null,
315
+ touched: [],
316
+ });
317
+ return {
318
+ wizardId,
319
+ shareUrl: shareUrl(shareToken ?? row.shareToken),
320
+ code: await codeOf(shareToken ?? row.shareToken),
321
+ shareEnabled: row.shareEnabled,
322
+ publishedVersion,
323
+ runnable,
324
+ problems,
325
+ };
326
+ });
327
+ }
328
+ async function syncedWizard(rawSpaceId, rawWizardId) {
329
+ const spaceId = syncedId.parse(rawSpaceId);
330
+ const wizardId = syncedId.parse(rawWizardId);
331
+ const project = await syncedProject(spaceId);
332
+ const w = project?.origin === "local"
333
+ ? await db.query.wizard.findFirst({
334
+ where: and(eq(schema.wizard.id, wizardId), eq(schema.wizard.projectId, spaceId)),
335
+ })
336
+ : null;
337
+ if (!w) {
338
+ throw notFound();
339
+ }
340
+ return w;
341
+ }
342
+ /** The install changed how a synced wizard is shared: its link on or off, its runs a day. */
343
+ export async function patchSyncedWizard(spaceId, wizardId, patch) {
344
+ const w = await syncedWizard(spaceId, wizardId);
345
+ await db
346
+ .update(schema.wizard)
347
+ .set({ ...patch, updatedAt: new Date() })
348
+ .where(eq(schema.wizard.id, w.id));
349
+ return { ok: true };
350
+ }
351
+ /** The install deleted a wizard: its copy here goes, with its link and its runs. */
352
+ export async function removeSyncedWizard(userId, spaceId, wizardId) {
353
+ const w = await syncedWizard(spaceId, wizardId);
354
+ await asSync(() => deleteWizard(userId, w.id));
355
+ return { ok: true };
356
+ }
357
+ /** The projects local installs synced, with their wizards: what an install compares itself to. */
358
+ export async function listSyncedSpaces() {
359
+ const projects = await db.query.project.findMany({
360
+ where: and(eq(schema.project.tenantId, currentTenant()), eq(schema.project.origin, "local")),
361
+ orderBy: [schema.project.createdAt],
362
+ });
363
+ const wizards = projects.length
364
+ ? await db
365
+ .select({
366
+ id: schema.wizard.id,
367
+ projectId: schema.wizard.projectId,
368
+ title: schema.wizard.title,
369
+ publishedVersion: schema.wizard.publishedVersion,
370
+ shareToken: schema.wizard.shareToken,
371
+ shareEnabled: schema.wizard.shareEnabled,
372
+ })
373
+ .from(schema.wizard)
374
+ .where(inArray(schema.wizard.projectId, projects.map((p) => p.id)))
375
+ : [];
376
+ return projects.map((p) => ({
377
+ id: p.id,
378
+ name: p.name,
379
+ syncedAt: p.syncedAt?.toISOString() ?? null,
380
+ wizards: wizards
381
+ .filter((w) => w.projectId === p.id)
382
+ .map((w) => ({
383
+ id: w.id,
384
+ title: w.title,
385
+ publishedVersion: w.publishedVersion,
386
+ shareUrl: shareUrl(w.shareToken),
387
+ shareEnabled: w.shareEnabled,
388
+ })),
389
+ }));
390
+ }
391
+ /** Takes a synced project away from this runtime: its wizards, their links and runs, its files. */
392
+ export async function removeSyncedSpace(rawSpaceId) {
393
+ const spaceId = syncedId.parse(rawSpaceId);
394
+ const project = await syncedProject(spaceId);
395
+ if (project?.origin !== "local") {
396
+ throw notFound();
397
+ }
398
+ await asSync(() => removeProject(spaceId));
399
+ return { ok: true };
400
+ }
@@ -8,6 +8,7 @@ import { env } from "../env.js";
8
8
  import { pluginToolsOf } from "../plugins/registry.js";
9
9
  import { dropLinks, putLink } from "../tenants/control.js";
10
10
  import { currentTenant } from "../tenants/tenant.js";
11
+ import { requireBuild, requireWritable, requireWritableProject } from "./access.js";
11
12
  import { changedSteps, emitDraftChanged } from "./draft-events.js";
12
13
  import { notFound, ServiceError } from "./errors.js";
13
14
  import { copyFiles, draftFiles, sameFiles, seedFiles } from "./files.js";
@@ -41,6 +42,12 @@ export async function ownedWizard(_userId, wizardId) {
41
42
  }
42
43
  return w;
43
44
  }
45
+ /** The wizard, for a change: refused where its project is a local install's or nothing is built here. */
46
+ export async function writableWizard(userId, wizardId) {
47
+ const w = await ownedWizard(userId, wizardId);
48
+ await requireWritableProject(w.projectId);
49
+ return w;
50
+ }
44
51
  export function wizardSummary(w) {
45
52
  return {
46
53
  id: w.id,
@@ -193,9 +200,12 @@ export function shapeIssues(error, raw) {
193
200
  });
194
201
  }
195
202
  export async function createWizard(userId, input, writer = { source: "studio" }) {
203
+ // Asked first: a tenant that builds nothing here has no project of its own to name.
204
+ await requireBuild();
196
205
  const project = input.projectId
197
206
  ? await ownedProject(userId, input.projectId)
198
207
  : await defaultProject(userId);
208
+ await requireWritable(project);
199
209
  const starter = input.starterId
200
210
  ? await marketplaceWizard(input.starterId, asLang(input.lang))
201
211
  : undefined;
@@ -243,7 +253,7 @@ export async function createWizard(userId, input, writer = { source: "studio" })
243
253
  * architect, MCP — goes through here, so the revision counts all of them.
244
254
  */
245
255
  export async function writeDraft(userId, wizardId, input, writer = { source: "studio" }) {
246
- const w = await ownedWizard(userId, wizardId);
256
+ const w = await writableWizard(userId, wizardId);
247
257
  const files = await draftFiles(w.id);
248
258
  const paths = files.map((f) => f.path);
249
259
  const { draft } = parseDraft(input.definition, paths);
@@ -279,7 +289,7 @@ export async function writeDraft(userId, wizardId, input, writer = { source: "st
279
289
  return { revision: row.revision, issues, draft };
280
290
  }
281
291
  export async function editWizard(userId, wizardId, input, writer) {
282
- const w = await ownedWizard(userId, wizardId);
292
+ const w = await writableWizard(userId, wizardId);
283
293
  if (w.revision !== input.baseRevision) {
284
294
  throw new ServiceError("revision_conflict", "Der Wizard wurde inzwischen geändert.", {
285
295
  revision: w.revision,
@@ -298,9 +308,9 @@ export async function editWizard(userId, wizardId, input, writer) {
298
308
  return writeDraft(userId, wizardId, { definition, baseRevision: input.baseRevision, note: input.note }, writer);
299
309
  }
300
310
  export async function updateWizardSettings(userId, wizardId, patch) {
301
- const w = await ownedWizard(userId, wizardId);
311
+ const w = await writableWizard(userId, wizardId);
302
312
  if (patch.projectId) {
303
- await ownedProject(userId, patch.projectId);
313
+ await requireWritable(await ownedProject(userId, patch.projectId));
304
314
  }
305
315
  await db
306
316
  .update(schema.wizard)
@@ -309,7 +319,7 @@ export async function updateWizardSettings(userId, wizardId, patch) {
309
319
  return wizardSummary(await ownedWizard(userId, wizardId));
310
320
  }
311
321
  export async function rotateShareLink(userId, wizardId) {
312
- const w = await ownedWizard(userId, wizardId);
322
+ const w = await writableWizard(userId, wizardId);
313
323
  const token = newShareToken();
314
324
  await db
315
325
  .update(schema.wizard)
@@ -320,7 +330,7 @@ export async function rotateShareLink(userId, wizardId) {
320
330
  return { shareToken: token, shareUrl: shareUrl(token) };
321
331
  }
322
332
  export async function duplicateWizard(userId, wizardId) {
323
- const w = await ownedWizard(userId, wizardId);
333
+ const w = await writableWizard(userId, wizardId);
324
334
  const id = nanoid(12);
325
335
  const draft = { ...w.draft, title: `${w.draft.title} (Kopie)` };
326
336
  const shareToken = newShareToken();
@@ -338,7 +348,8 @@ export async function duplicateWizard(userId, wizardId) {
338
348
  await copyFiles(w.id, id);
339
349
  return { id };
340
350
  }
341
- export async function deleteWizard(_userId, wizardId) {
351
+ export async function deleteWizard(userId, wizardId) {
352
+ await writableWizard(userId, wizardId);
342
353
  await forgetWizardLinks(wizardId);
343
354
  await db
344
355
  .delete(schema.wizard)
@@ -354,7 +365,7 @@ export async function requireClean(w) {
354
365
  return { definition: w.draft, files };
355
366
  }
356
367
  export async function publishWizard(userId, wizardId) {
357
- const w = await ownedWizard(userId, wizardId);
368
+ const w = await writableWizard(userId, wizardId);
358
369
  const { definition, files } = await requireClean(w);
359
370
  const version = (w.publishedVersion ?? 0) + 1;
360
371
  await db
@@ -12,10 +12,57 @@ export async function putLink(token, kind, ref) {
12
12
  set: { tenantId: currentTenant(), kind, ref },
13
13
  });
14
14
  }
15
+ /** A token's link goes, and with a wizard's share token its ID. */
15
16
  export async function dropLinks(tokens) {
16
17
  if (tokens.length) {
17
18
  await controlDb.delete(control.link).where(inArray(control.link.token, tokens));
19
+ await controlDb
20
+ .delete(control.link)
21
+ .where(and(eq(control.link.kind, "code"), inArray(control.link.ref, tokens)));
22
+ }
23
+ }
24
+ // --- wizard IDs ----------------------------------------------------------------------
25
+ // The share token is 14 characters of both cases, `_` and `-`: nobody types that on a phone.
26
+ // The ID is 8 capitals and digits without look-alikes (no 0 O 1 I), made the first time someone
27
+ // asks for it and gone when the share token rotates.
28
+ const CODE_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
29
+ const CODE_LENGTH = 8;
30
+ function newCode() {
31
+ const bytes = crypto.getRandomValues(new Uint8Array(CODE_LENGTH));
32
+ return Array.from(bytes, (b) => CODE_ALPHABET[b % CODE_ALPHABET.length]).join("");
33
+ }
34
+ /** What a person typed, as stored: capitals, no spaces or dashes. */
35
+ export const normalizeCode = (input) => input.toUpperCase().replace(/[\s-]/g, "");
36
+ /** The ID of a wizard's share token; made on the first ask. */
37
+ export async function codeOf(shareToken) {
38
+ const row = await controlDb.query.link.findFirst({
39
+ where: and(eq(control.link.kind, "code"), eq(control.link.ref, shareToken)),
40
+ });
41
+ if (row) {
42
+ return row.token;
18
43
  }
44
+ for (;;) {
45
+ const code = newCode();
46
+ const made = await controlDb
47
+ .insert(control.link)
48
+ .values({ token: code, tenantId: currentTenant(), kind: "code", ref: shareToken })
49
+ .onConflictDoNothing()
50
+ .returning({ token: control.link.token });
51
+ if (made.length) {
52
+ return code;
53
+ }
54
+ }
55
+ }
56
+ /** The share token behind an ID, and its tenant. */
57
+ export async function tokenOfCode(input) {
58
+ const code = normalizeCode(input);
59
+ if (code.length !== CODE_LENGTH) {
60
+ return null;
61
+ }
62
+ const row = await controlDb.query.link.findFirst({
63
+ where: and(eq(control.link.kind, "code"), eq(control.link.token, code)),
64
+ });
65
+ return row ? { token: row.ref, tenantId: row.tenantId } : null;
19
66
  }
20
67
  export async function tenantOfLink(token, kind) {
21
68
  const row = await controlDb.query.link.findFirst({
@@ -1 +1 @@
1
- import{a as e,n as t,t as n}from"./jsx-runtime-D3jfb0Ew.js";import{t as r}from"./preload-helper-CNEI7N0N.js";import{a as i,d as a,h as o,i as s,s as c,y as l}from"./ui-Cs6lpsmj.js";var u=e(t(),1),d=e(n(),1);function f(e){switch(e.group){case`Added`:return{label:`NEW`,dot:`bg-moss`};case`Fixed`:return{label:`FIXED`,dot:`bg-amber`};case`Changed`:return{label:`CHANGED`,dot:`bg-cobalt`};case`Performance`:return{label:`PERFORMANCE`,dot:`bg-ember`};case`Deploy`:return{label:`DEPLOY`,dot:`bg-ink-4`};case`Docs`:return{label:`DOCS`,dot:`bg-ink-4`};default:return{label:`OTHER`,dot:`bg-ink-4`}}}var p=e=>e.version?e.version.replace(/^v/,``):l(`about.unreleased`),m=e=>e.timestamp?new Date(e.timestamp*1e3).toISOString().slice(0,10):``,h=e=>e.charAt(0).toUpperCase()+e.slice(1);function g(e,t){let n=t.trim().toLowerCase();if(!n)return e;let r=[];for(let t of e){if(`${p(t)} ${m(t)}`.toLowerCase().includes(n)){r.push(t);continue}let e=t.commits.filter(e=>`${f(e).label} ${e.scope??``} ${e.message}`.toLowerCase().includes(n));e.length>0&&r.push({...t,commits:e})}return r}function _({open:e,onClose:t}){let[n,_]=(0,u.useState)(null),[v,y]=(0,u.useState)(``);(0,u.useEffect)(()=>{if(!e||n)return;let t=!1;return r(()=>import(`./changelog-C2LDEzLQ.js`).then(e=>{t||_(e.default)}),[]),()=>{t=!0}},[e,n]),(0,u.useEffect)(()=>{e||y(``)},[e]);let b=n?g(n,v):[];return(0,d.jsxs)(s,{open:e,onClose:t,title:l(`about.changelog`),wide:!0,children:[(0,d.jsx)(`p`,{className:`-mt-3 mb-4 text-[14px] text-ink-3`,children:l(`about.changelogHint`)}),n===null?(0,d.jsx)(`div`,{className:`flex justify-center py-10 text-ink-3`,children:(0,d.jsx)(a,{})}):n.length===0?(0,d.jsx)(i,{children:l(`about.changelogEmpty`)}):(0,d.jsxs)(d.Fragment,{children:[(0,d.jsx)(c,{type:`search`,value:v,placeholder:l(`about.search`),onChange:e=>y(e.target.value)}),b.length===0?(0,d.jsx)(i,{children:l(`about.noMatches`)}):null,b.map(e=>(0,d.jsxs)(`section`,{className:`mt-6`,children:[(0,d.jsxs)(`h3`,{className:`flex items-baseline gap-3 border-border-soft border-b pb-2 font-display font-semibold text-[16px]`,children:[p(e),(0,d.jsx)(`span`,{className:`font-normal font-sans text-[13px] text-ink-3 tabular-nums`,children:m(e)})]}),(0,d.jsx)(`ul`,{className:`mt-3 flex flex-col gap-2`,children:e.commits.map(e=>{let t=f(e);return(0,d.jsxs)(`li`,{className:`flex items-start gap-2.5 text-[14px]`,children:[(0,d.jsx)(`span`,{className:o(`mt-[7px] size-1.5 shrink-0 rounded-full`,t.dot)}),(0,d.jsxs)(`span`,{className:`min-w-0 text-ink-2`,children:[(0,d.jsxs)(`span`,{className:`font-semibold text-[12px] text-ink tracking-wide`,children:[t.label,e.scope?` [${e.scope}]`:``]}),` `,h(e.message),e.breaking?(0,d.jsx)(`span`,{className:`ml-1.5 rounded-full bg-rose-tint px-1.5 py-0.5 font-medium text-[12px] text-rose`,children:l(`about.breaking`)}):null]})]},e.id)})})]},e.version??`unreleased`))]})]})}export{_ as ChangelogDialog};
1
+ import{a as e,n as t,t as n}from"./jsx-runtime-D3jfb0Ew.js";import{t as r}from"./preload-helper-CNEI7N0N.js";import{a as i,d as a,h as o,i as s,s as c,y as l}from"./ui-iTYHqwvY.js";var u=e(t(),1),d=e(n(),1);function f(e){switch(e.group){case`Added`:return{label:`NEW`,dot:`bg-moss`};case`Fixed`:return{label:`FIXED`,dot:`bg-amber`};case`Changed`:return{label:`CHANGED`,dot:`bg-cobalt`};case`Performance`:return{label:`PERFORMANCE`,dot:`bg-ember`};case`Deploy`:return{label:`DEPLOY`,dot:`bg-ink-4`};case`Docs`:return{label:`DOCS`,dot:`bg-ink-4`};default:return{label:`OTHER`,dot:`bg-ink-4`}}}var p=e=>e.version?e.version.replace(/^v/,``):l(`about.unreleased`),m=e=>e.timestamp?new Date(e.timestamp*1e3).toISOString().slice(0,10):``,h=e=>e.charAt(0).toUpperCase()+e.slice(1);function g(e,t){let n=t.trim().toLowerCase();if(!n)return e;let r=[];for(let t of e){if(`${p(t)} ${m(t)}`.toLowerCase().includes(n)){r.push(t);continue}let e=t.commits.filter(e=>`${f(e).label} ${e.scope??``} ${e.message}`.toLowerCase().includes(n));e.length>0&&r.push({...t,commits:e})}return r}function _({open:e,onClose:t}){let[n,_]=(0,u.useState)(null),[v,y]=(0,u.useState)(``);(0,u.useEffect)(()=>{if(!e||n)return;let t=!1;return r(()=>import(`./changelog-Cto0j5-V.js`).then(e=>{t||_(e.default)}),[]),()=>{t=!0}},[e,n]),(0,u.useEffect)(()=>{e||y(``)},[e]);let b=n?g(n,v):[];return(0,d.jsxs)(s,{open:e,onClose:t,title:l(`about.changelog`),wide:!0,children:[(0,d.jsx)(`p`,{className:`-mt-3 mb-4 text-[14px] text-ink-3`,children:l(`about.changelogHint`)}),n===null?(0,d.jsx)(`div`,{className:`flex justify-center py-10 text-ink-3`,children:(0,d.jsx)(a,{})}):n.length===0?(0,d.jsx)(i,{children:l(`about.changelogEmpty`)}):(0,d.jsxs)(d.Fragment,{children:[(0,d.jsx)(c,{type:`search`,value:v,placeholder:l(`about.search`),onChange:e=>y(e.target.value)}),b.length===0?(0,d.jsx)(i,{children:l(`about.noMatches`)}):null,b.map(e=>(0,d.jsxs)(`section`,{className:`mt-6`,children:[(0,d.jsxs)(`h3`,{className:`flex items-baseline gap-3 border-border-soft border-b pb-2 font-display font-semibold text-[16px]`,children:[p(e),(0,d.jsx)(`span`,{className:`font-normal font-sans text-[13px] text-ink-3 tabular-nums`,children:m(e)})]}),(0,d.jsx)(`ul`,{className:`mt-3 flex flex-col gap-2`,children:e.commits.map(e=>{let t=f(e);return(0,d.jsxs)(`li`,{className:`flex items-start gap-2.5 text-[14px]`,children:[(0,d.jsx)(`span`,{className:o(`mt-[7px] size-1.5 shrink-0 rounded-full`,t.dot)}),(0,d.jsxs)(`span`,{className:`min-w-0 text-ink-2`,children:[(0,d.jsxs)(`span`,{className:`font-semibold text-[12px] text-ink tracking-wide`,children:[t.label,e.scope?` [${e.scope}]`:``]}),` `,h(e.message),e.breaking?(0,d.jsx)(`span`,{className:`ml-1.5 rounded-full bg-rose-tint px-1.5 py-0.5 font-medium text-[12px] text-rose`,children:l(`about.breaking`)}):null]})]},e.id)})})]},e.version??`unreleased`))]})]})}export{_ as ChangelogDialog};