@jxsuite/studio 0.35.0 → 0.37.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,805 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * Cloud platform adapter (PAL) — implements StudioPlatform against a cloud backend speaking the
4
+ * Studio Backend Protocol behind a session gateway: file/git operations go to
5
+ * `/api/v1/p/:owner/:repo/:branch/studio/*` (cookie auth), AI to the backend's proxy at
6
+ * `/api/v1/ai/chat`. The session is pre-bound to one (repo, branch) — the shell picks the project
7
+ * before Studio boots — while project-less mode (null) powers the /studio welcome screen.
8
+ *
9
+ * Cloud omissions (Studio degrades per @jxsuite/protocol's route table): pickDirectory/importSite,
10
+ * package install/outdated/set-versions (package ops are manifest-only edits), multi-window,
11
+ * gitClone, resolveClass, component discovery, code services.
12
+ */
13
+
14
+ /* Import the browser-safe primitives directly (NOT the Markdown class —
15
+ its node-only discover/load chain drags glob/node:fs into the bundle). */
16
+ import { transpileJxMarkdown } from "@jxsuite/parser/transpile";
17
+ import { serializeJxMarkdown } from "@jxsuite/parser/serialize";
18
+ import markdownClassDef from "@jxsuite/parser/Markdown.class.json";
19
+ import type { WsCollabConnection } from "@jxsuite/collab/client";
20
+ import type { JxDocument, ProjectConfig } from "@jxsuite/schema/types";
21
+ import type {
22
+ DirEntry,
23
+ FsEvent,
24
+ GitBranchesResult,
25
+ GitLogEntry,
26
+ GitStatusResult,
27
+ PackageInfo,
28
+ ProjectListEntry,
29
+ RenameResult,
30
+ StarterInfo,
31
+ StudioPlatform,
32
+ } from "../types";
33
+
34
+ export interface CloudProject {
35
+ owner: string;
36
+ repo: string;
37
+ branch: string;
38
+ }
39
+
40
+ interface ProjectInfoWire {
41
+ root: string;
42
+ name: string;
43
+ defaultBranch: string;
44
+ permission: "admin" | "write" | "read" | "none";
45
+ projectConfig: Record<string, unknown> | null;
46
+ }
47
+
48
+ interface SessionEventWire {
49
+ kind: "fs" | "git";
50
+ events?: FsEvent[];
51
+ event?: string;
52
+ sha?: string;
53
+ }
54
+
55
+ /** Message-level failure body every platform route uses. */
56
+ interface ErrorBody {
57
+ error?: string;
58
+ code?: string;
59
+ }
60
+
61
+ async function errorMessage(res: Response, fallback: string): Promise<string> {
62
+ try {
63
+ const body = (await res.json()) as ErrorBody;
64
+ return body.error ?? fallback;
65
+ } catch {
66
+ return fallback;
67
+ }
68
+ }
69
+
70
+ async function okJson<T>(res: Response, fallback: string): Promise<T> {
71
+ if (!res.ok) {
72
+ throw new Error(await errorMessage(res, fallback));
73
+ }
74
+ return (await res.json()) as T;
75
+ }
76
+
77
+ /**
78
+ * Formats the cloud can execute: browser-safe built-ins only (parse/serialize run IN the editor
79
+ * page via @jxsuite/parser — the platform never executes project JS). The registry entry mirrors
80
+ * the devserver's `GET /__studio/formats` shape, sourced from the canonical class descriptor.
81
+ * Unlike the devserver we register Markdown regardless of project.json imports — the compiler still
82
+ * requires the import for `jx build`, but the editor should never strand a .md file unopenable.
83
+ */
84
+ interface MarkdownClassDef {
85
+ format: {
86
+ extensions: string[];
87
+ mediaType: string;
88
+ documentKinds: string[];
89
+ exportTarget: boolean;
90
+ remote: boolean;
91
+ };
92
+ $studio: Record<string, unknown>;
93
+ }
94
+
95
+ function builtinFormats(config: ProjectConfig | null): Record<string, unknown>[] {
96
+ const def = markdownClassDef as unknown as MarkdownClassDef;
97
+ const imports = (config?.imports ?? {}) as Record<string, string>;
98
+ const importedName = Object.keys(imports).find((name) =>
99
+ imports[name]?.includes("Markdown.class.json"),
100
+ );
101
+ return [
102
+ {
103
+ name: importedName ?? "Markdown",
104
+ extensions: def.format.extensions,
105
+ mediaType: def.format.mediaType,
106
+ documentKinds: def.format.documentKinds,
107
+ exportTarget: def.format.exportTarget,
108
+ remote: def.format.remote,
109
+ studio: def.$studio,
110
+ capabilities: {
111
+ parse: { identifier: "parse", timing: ["compiler", "server", "client"] },
112
+ serialize: { identifier: "serialize", timing: ["compiler", "server", "client"] },
113
+ },
114
+ },
115
+ ];
116
+ }
117
+
118
+ /** Editor URL for a project session (mirrors the shell's route). */
119
+ export function editUrl(project: CloudProject): string {
120
+ return `/edit/${project.owner}/${project.repo}@${encodeURIComponent(project.branch)}`;
121
+ }
122
+
123
+ /** Parse an /edit/:owner/:repo@:branch path (editUrl's inverse); null when it is not one. */
124
+ export function parseEditPath(pathname: string): CloudProject | null {
125
+ /* Asset routers may normalize "@" to "%40", so decode the whole path first
126
+ (branch slashes survive: `.+` spans them). */
127
+ let decoded: string;
128
+ try {
129
+ decoded = decodeURIComponent(pathname);
130
+ } catch {
131
+ return null;
132
+ }
133
+ const match = /^\/edit\/([^/]+)\/([^/@]+)@(.+)$/.exec(decoded);
134
+ if (!match) {
135
+ return null;
136
+ }
137
+ const [, owner, repo, branch] = match;
138
+ if (!owner || !repo || !branch) {
139
+ return null;
140
+ }
141
+ return { owner, repo, branch };
142
+ }
143
+
144
+ /** Gateway base path for a project session. */
145
+ export function sessionBase(project: CloudProject): string {
146
+ const { owner, repo, branch } = project;
147
+ return `/api/v1/p/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${encodeURIComponent(branch)}/studio`;
148
+ }
149
+
150
+ /** Catalogue/recents root key for a project: "owner/repo@branch". */
151
+ export function projectRootKey(project: CloudProject): string {
152
+ return `${project.owner}/${project.repo}@${project.branch}`;
153
+ }
154
+
155
+ /** Current hosted Cloudflare connection; null when none is brokered yet. */
156
+ async function fetchCfConnection(): Promise<{
157
+ connected: boolean;
158
+ accountId?: string | undefined;
159
+ accountName?: string | undefined;
160
+ } | null> {
161
+ const res = await fetch("/api/v1/cf/connection", { credentials: "include" });
162
+ if (!res.ok) {
163
+ return null;
164
+ }
165
+ const body = (await res.json()) as {
166
+ connected: boolean;
167
+ accountId?: string | null;
168
+ accountName?: string | null;
169
+ };
170
+ if (!body.connected) {
171
+ return null;
172
+ }
173
+ return {
174
+ connected: true,
175
+ ...(body.accountId ? { accountId: body.accountId } : {}),
176
+ ...(body.accountName ? { accountName: body.accountName } : {}),
177
+ };
178
+ }
179
+
180
+ /** Parse an "owner/repo@branch" root key; null when malformed. */
181
+ export function parseRootKey(root: string): CloudProject | null {
182
+ const match = /^([^/@]+)\/([^/@]+)@(.+)$/.exec(root);
183
+ if (!match) {
184
+ return null;
185
+ }
186
+ const [, owner, repo, branch] = match;
187
+ if (!owner || !repo || !branch) {
188
+ return null;
189
+ }
190
+ return { owner, repo, branch };
191
+ }
192
+
193
+ /**
194
+ * Bound mode (project non-null) drives one repo+branch session; project-less mode (null, the
195
+ * /studio route) exposes only the catalogue surface — listProjects/listStarters/createProject and
196
+ * the navigation members — so Studio's own welcome screen and New Project modal do the rest.
197
+ */
198
+ export function createCloudPlatform(project: CloudProject | null): StudioPlatform {
199
+ const base = project ? sessionBase(project) : "";
200
+ const root = project ? `${project.owner}/${project.repo}` : "";
201
+ /**
202
+ * One multiplexed collab socket per session; per-doc handles come from openDoc. Memoized as a
203
+ * promise so concurrent first opens share the connection instead of racing two sockets.
204
+ */
205
+ let collabConnection: Promise<WsCollabConnection> | null = null;
206
+
207
+ function api(path: string, init?: RequestInit): Promise<Response> {
208
+ if (!project) {
209
+ return Promise.reject(new Error("No project is open in this session"));
210
+ }
211
+ return fetch(`${base}${path}`, { credentials: "include", ...init });
212
+ }
213
+
214
+ function postJson(path: string, body: unknown): Promise<Response> {
215
+ return api(path, {
216
+ method: "POST",
217
+ headers: { "Content-Type": "application/json" },
218
+ body: JSON.stringify(body),
219
+ });
220
+ }
221
+
222
+ async function projectInfo(): Promise<ProjectInfoWire> {
223
+ return okJson<ProjectInfoWire>(await api("/project-info"), "Failed to load project");
224
+ }
225
+
226
+ async function readPackageJson(): Promise<Record<string, unknown>> {
227
+ const res = await api(`/file?path=${encodeURIComponent("package.json")}`);
228
+ if (!res.ok) {
229
+ return {};
230
+ }
231
+ const data = (await res.json()) as { content: string };
232
+ try {
233
+ return JSON.parse(data.content) as Record<string, unknown>;
234
+ } catch {
235
+ return {};
236
+ }
237
+ }
238
+
239
+ async function writePackageJson(pkg: Record<string, unknown>): Promise<void> {
240
+ const res = await api("/file", {
241
+ method: "PUT",
242
+ headers: { "Content-Type": "application/json" },
243
+ body: JSON.stringify({ path: "package.json", content: `${JSON.stringify(pkg, null, 2)}\n` }),
244
+ });
245
+ if (!res.ok) {
246
+ throw new Error(await errorMessage(res, "Failed to update package.json"));
247
+ }
248
+ }
249
+
250
+ let cachedConfig: ProjectConfig | null = null;
251
+
252
+ const platform: StudioPlatform = {
253
+ id: "cloud",
254
+ projectRoot: root,
255
+ canvasUrl: "/canvas.html",
256
+
257
+ async activate() {
258
+ if (!project) {
259
+ return;
260
+ }
261
+ await postJson("/activate", {});
262
+ },
263
+
264
+ /* The shell binds the session to one repo+branch before Studio boots, so
265
+ "opening" a project just returns the bound one. */
266
+ async openProject() {
267
+ if (!project) {
268
+ return null;
269
+ }
270
+ const info = await projectInfo();
271
+ const config = (info.projectConfig ?? {}) as ProjectConfig;
272
+ return {
273
+ config,
274
+ handle: { root, name: config.name || info.name, projectConfig: config },
275
+ };
276
+ },
277
+
278
+ async probeRootProject() {
279
+ if (!project) {
280
+ return null;
281
+ }
282
+ try {
283
+ const info = await projectInfo();
284
+ return {
285
+ meta: { root, name: info.name },
286
+ info: {
287
+ isSiteProject: info.projectConfig !== null,
288
+ projectConfig: (info.projectConfig as ProjectConfig | null) ?? null,
289
+ },
290
+ };
291
+ } catch {
292
+ return null;
293
+ }
294
+ },
295
+
296
+ // ─── Files ────────────────────────────────────────────────────────────
297
+
298
+ async listDirectory(dir: string) {
299
+ return okJson<DirEntry[]>(
300
+ await api(`/files?dir=${encodeURIComponent(dir)}`),
301
+ `Failed to list directory: ${dir}`,
302
+ );
303
+ },
304
+
305
+ async readFile(path: string) {
306
+ const data = await okJson<{ content: string }>(
307
+ await api(`/file?path=${encodeURIComponent(path)}`),
308
+ `Failed to read file: ${path}`,
309
+ );
310
+ return data.content;
311
+ },
312
+
313
+ async writeFile(path: string, content: string) {
314
+ const res = await api("/file", {
315
+ method: "PUT",
316
+ headers: { "Content-Type": "application/json" },
317
+ body: JSON.stringify({ path, content }),
318
+ });
319
+ if (!res.ok) {
320
+ throw new Error(await errorMessage(res, `Failed to write file: ${path}`));
321
+ }
322
+ },
323
+
324
+ async uploadFile(path: string, data: string | File | Blob | ArrayBuffer) {
325
+ const res = await api(`/file/upload?path=${encodeURIComponent(path)}`, {
326
+ method: "POST",
327
+ body: data,
328
+ });
329
+ return okJson<unknown>(res, `Upload failed: ${path}`);
330
+ },
331
+
332
+ async deleteFile(path: string) {
333
+ const res = await api(`/file?path=${encodeURIComponent(path)}`, { method: "DELETE" });
334
+ if (!res.ok && res.status !== 404) {
335
+ throw new Error(await errorMessage(res, `Failed to delete file: ${path}`));
336
+ }
337
+ },
338
+
339
+ async renameFile(from: string, to: string): Promise<RenameResult> {
340
+ return okJson<RenameResult>(
341
+ await postJson("/file/rename", { from, to }),
342
+ `Failed to rename: ${from} → ${to}`,
343
+ );
344
+ },
345
+
346
+ async createDirectory(_path: string) {
347
+ // Directories exist implicitly in the virtual tree (created on write).
348
+ },
349
+
350
+ /**
351
+ * Realtime co-editing over the gateway's /collab WebSocket (rooms keyed by project-relative
352
+ * path, per the shared ProjectSession working tree). Backends without the endpoint (or with the
353
+ * flag off) refuse the upgrade and Studio degrades to solo editing. The wire client's
354
+ * evaluation defers behind the dynamic import until a doc opens.
355
+ */
356
+ async collab(docPath: string) {
357
+ if (!project || typeof WebSocket === "undefined" || typeof location === "undefined") {
358
+ return null;
359
+ }
360
+ collabConnection ??= (async () => {
361
+ const { createWsCollabConnection } = await import("@jxsuite/collab/client");
362
+ const scheme = location.protocol === "https:" ? "wss" : "ws";
363
+ return createWsCollabConnection({
364
+ hydratePath: async (path) => {
365
+ // The DO has no GitHub token on a WS message; a plain read hydrates + caches the row.
366
+ await api(`/file?path=${encodeURIComponent(path)}`);
367
+ },
368
+ url: `${scheme}://${location.host}${base}/collab`,
369
+ });
370
+ })();
371
+ const connection = await collabConnection;
372
+ return connection.openDoc(docPath);
373
+ },
374
+
375
+ /**
376
+ * Live session events over the gateway WebSocket. Reconnects with a small backoff; the DO
377
+ * pushes {kind:"fs"} batches for file mutations (including those from other tabs) and
378
+ * {kind:"git"} notices this handler ignores.
379
+ */
380
+ subscribeFileEvents(handler: (events: FsEvent[]) => void) {
381
+ if (typeof WebSocket === "undefined" || typeof location === "undefined") {
382
+ return () => {};
383
+ }
384
+ let socket: WebSocket | null = null;
385
+ let closed = false;
386
+ let retryMs = 1000;
387
+ const connect = () => {
388
+ const scheme = location.protocol === "https:" ? "wss" : "ws";
389
+ socket = new WebSocket(`${scheme}://${location.host}${base}/events`);
390
+ socket.addEventListener("message", (ev: MessageEvent) => {
391
+ let payload: SessionEventWire;
392
+ try {
393
+ payload = JSON.parse(ev.data as string) as SessionEventWire;
394
+ } catch {
395
+ return;
396
+ }
397
+ if (payload.kind === "fs" && payload.events?.length) {
398
+ handler(payload.events);
399
+ }
400
+ });
401
+ socket.addEventListener("open", () => {
402
+ retryMs = 1000;
403
+ });
404
+ socket.addEventListener("close", () => {
405
+ if (!closed) {
406
+ setTimeout(connect, retryMs);
407
+ retryMs = Math.min(retryMs * 2, 30_000);
408
+ }
409
+ });
410
+ };
411
+ connect();
412
+ return () => {
413
+ closed = true;
414
+ socket?.close();
415
+ };
416
+ },
417
+
418
+ // ─── Formats (browser-safe built-ins, executed in-page) ───────────────
419
+
420
+ async listFormats() {
421
+ if (cachedConfig === null) {
422
+ try {
423
+ const info = await projectInfo();
424
+ cachedConfig = (info.projectConfig as ProjectConfig | null) ?? null;
425
+ } catch {
426
+ cachedConfig = null;
427
+ }
428
+ }
429
+ return builtinFormats(cachedConfig);
430
+ },
431
+
432
+ async formatAction(payload: Record<string, unknown>) {
433
+ const { action, source, doc, options } = payload as {
434
+ action: string;
435
+ source?: string;
436
+ doc?: Record<string, unknown>;
437
+ options?: Record<string, unknown>;
438
+ };
439
+ if (action === "parse") {
440
+ return transpileJxMarkdown(source ?? "");
441
+ }
442
+ if (action === "serialize") {
443
+ return serializeJxMarkdown((doc ?? {}) as JxDocument, options);
444
+ }
445
+ throw new Error(`Unsupported format action: ${action}`);
446
+ },
447
+
448
+ // ─── Components / code services (cloud: static-only posture) ──────────
449
+
450
+ async discoverComponents() {
451
+ // No execution of project JS in the cloud (MVP security posture).
452
+ return [];
453
+ },
454
+
455
+ async codeService() {
456
+ return null;
457
+ },
458
+
459
+ async fetchPluginSchema() {
460
+ return null;
461
+ },
462
+
463
+ // ─── Packages (manifest-only edits; resolution happens in Pages CI) ───
464
+
465
+ async addPackage(name: string) {
466
+ const pkg = await readPackageJson();
467
+ const deps = (pkg["dependencies"] ?? {}) as Record<string, string>;
468
+ const at = name.lastIndexOf("@");
469
+ const [pkgName, version] =
470
+ at > 0 ? [name.slice(0, at), name.slice(at + 1)] : [name, "latest"];
471
+ deps[pkgName] = version;
472
+ pkg["dependencies"] = deps;
473
+ await writePackageJson(pkg);
474
+ return { ok: true, name: pkgName, version };
475
+ },
476
+
477
+ async removePackage(name: string) {
478
+ const pkg = await readPackageJson();
479
+ for (const key of ["dependencies", "devDependencies"]) {
480
+ const deps = pkg[key] as Record<string, string> | undefined;
481
+ if (deps && name in deps) {
482
+ pkg[key] = Object.fromEntries(Object.entries(deps).filter(([dep]) => dep !== name));
483
+ }
484
+ }
485
+ await writePackageJson(pkg);
486
+ return { ok: true, name };
487
+ },
488
+
489
+ async listPackages(): Promise<PackageInfo[]> {
490
+ const pkg = await readPackageJson();
491
+ const out: PackageInfo[] = [];
492
+ const deps = (pkg["dependencies"] ?? {}) as Record<string, string>;
493
+ for (const [name, version] of Object.entries(deps)) {
494
+ out.push({ name, version });
495
+ }
496
+ const devDeps = (pkg["devDependencies"] ?? {}) as Record<string, string>;
497
+ for (const [name, version] of Object.entries(devDeps)) {
498
+ out.push({ name, version, dev: true });
499
+ }
500
+ return out;
501
+ },
502
+
503
+ // ─── Site context & lookup ─────────────────────────────────────────────
504
+
505
+ async resolveSiteContext(filePath: string) {
506
+ try {
507
+ const info = await projectInfo();
508
+ const config = (info.projectConfig as ProjectConfig | null) ?? undefined;
509
+ return {
510
+ sitePath: root,
511
+ ...(config === undefined ? {} : { projectConfig: config }),
512
+ ...(filePath === root ? {} : { fileRelPath: filePath }),
513
+ };
514
+ } catch {
515
+ return { sitePath: null };
516
+ }
517
+ },
518
+
519
+ async locateFile(name: string) {
520
+ try {
521
+ const res = await api(`/locate?name=${encodeURIComponent(name)}`);
522
+ if (res.ok) {
523
+ const body = (await res.json()) as { path?: string | null };
524
+ return body.path ?? null;
525
+ }
526
+ } catch {
527
+ // Fall through to null; the caller treats it as "not found".
528
+ }
529
+ return null;
530
+ },
531
+
532
+ async searchFiles(query: string, extensions: string[] = []) {
533
+ const exts = ["json", ...extensions.map((e) => e.replace(/^\./, ""))];
534
+ const res = await api(
535
+ `/search?query=${encodeURIComponent(query)}&extensions=${encodeURIComponent(exts.join(","))}`,
536
+ );
537
+ if (!res.ok) {
538
+ return [];
539
+ }
540
+ return (await res.json()) as DirEntry[];
541
+ },
542
+
543
+ // ─── Git (virtual engine in the ProjectSession DO) ─────────────────────
544
+
545
+ async gitStatus() {
546
+ return okJson<GitStatusResult>(await api("/git/status"), "Failed to read git status");
547
+ },
548
+
549
+ async gitBranches() {
550
+ return okJson<GitBranchesResult>(await api("/git/branches"), "Failed to list branches");
551
+ },
552
+
553
+ async gitLog(limit?: number) {
554
+ const q = limit ? `?limit=${limit}` : "";
555
+ return okJson<GitLogEntry[]>(await api(`/git/log${q}`), "Failed to read git log");
556
+ },
557
+
558
+ async gitStage(files: string[]) {
559
+ await okJson(await postJson("/git/stage", { files }), "Failed to stage files");
560
+ },
561
+
562
+ async gitUnstage(files: string[]) {
563
+ await okJson(await postJson("/git/unstage", { files }), "Failed to unstage files");
564
+ },
565
+
566
+ /** Commits land on GitHub immediately (blobs → tree → commit → ref CAS). */
567
+ async gitCommit(message: string) {
568
+ await okJson(await postJson("/git/commit", { message }), "Commit failed");
569
+ },
570
+
571
+ /** Every commit is already on GitHub; push is a sync check. */
572
+ async gitPush() {
573
+ await okJson(await postJson("/git/push", {}), "Push failed");
574
+ },
575
+
576
+ async gitPull() {
577
+ await okJson(await postJson("/git/pull", {}), "Pull failed");
578
+ },
579
+
580
+ async gitFetch() {
581
+ await okJson(await postJson("/git/fetch", {}), "Fetch failed");
582
+ },
583
+
584
+ /** Branches are separate cloud sessions; re-point the page at the sibling. */
585
+ async gitCheckout(branch: string) {
586
+ if (project && typeof location !== "undefined") {
587
+ location.assign(editUrl({ ...project, branch }));
588
+ }
589
+ },
590
+
591
+ async gitCreateBranch(name: string) {
592
+ await okJson(await postJson("/git/create-branch", { name }), "Failed to create branch");
593
+ },
594
+
595
+ async gitDiff(path?: string) {
596
+ const data = await okJson<{ diff: string }>(
597
+ await api(`/git/diff?path=${encodeURIComponent(path ?? "")}`),
598
+ "Failed to compute diff",
599
+ );
600
+ return data.diff;
601
+ },
602
+
603
+ async gitShow(opts: { path: string; ref?: string }) {
604
+ const params = new URLSearchParams({ path: opts.path });
605
+ if (opts.ref) {
606
+ params.set("ref", opts.ref);
607
+ }
608
+ const data = await okJson<{ content: string }>(
609
+ await api(`/git/show?${params}`),
610
+ `Failed to read ${opts.path} at ref`,
611
+ );
612
+ return data.content;
613
+ },
614
+
615
+ async gitDiscard(files: string[]) {
616
+ await okJson(await postJson("/git/discard", { files }), "Failed to discard changes");
617
+ },
618
+
619
+ async gitInit() {
620
+ // Cloud projects are always GitHub repositories already.
621
+ },
622
+
623
+ async gitAddRemote() {
624
+ // The GitHub repo IS the remote; nothing to add.
625
+ },
626
+
627
+ // ─── Project catalogue & navigation (Studio welcome / New Project UI) ──
628
+
629
+ async listProjects(): Promise<ProjectListEntry[]> {
630
+ const res = await fetch("/api/v1/projects", { credentials: "include" });
631
+ if (!res.ok) {
632
+ return [];
633
+ }
634
+ const entries = (await res.json()) as {
635
+ fullName: string;
636
+ owner: string;
637
+ name: string;
638
+ defaultBranch: string;
639
+ permission: string;
640
+ }[];
641
+ return entries.map((p) => ({
642
+ name: p.fullName,
643
+ root: projectRootKey({ owner: p.owner, repo: p.name, branch: p.defaultBranch }),
644
+ description: `${p.defaultBranch} · ${p.permission}`,
645
+ }));
646
+ },
647
+
648
+ async listStarters(): Promise<StarterInfo[]> {
649
+ const res = await fetch("/api/v1/starters", { credentials: "include" });
650
+ if (!res.ok) {
651
+ return [];
652
+ }
653
+ return (await res.json()) as StarterInfo[];
654
+ },
655
+
656
+ /** Welcome/recents open path: navigate this tab into the project's editor. */
657
+ async setWindowProject(rootKey: string) {
658
+ const target = parseRootKey(rootKey);
659
+ if (target && typeof location !== "undefined") {
660
+ location.assign(editUrl(target));
661
+ }
662
+ // Navigation unloads the page; deduped stops the caller's follow-up work.
663
+ return { deduped: true, config: null };
664
+ },
665
+
666
+ async openProjectInNewWindow(rootKey: string) {
667
+ const target = parseRootKey(rootKey);
668
+ if (target && typeof window !== "undefined") {
669
+ window.open(editUrl(target), "_blank", "noopener");
670
+ }
671
+ },
672
+
673
+ /**
674
+ * Create the GitHub repo (seeded from a starter) via the platform API and return its catalogue
675
+ * root key — Studio's modal flow then opens it via setWindowProject. Server errors (e.g.
676
+ * needs_installation_access) surface as the thrown message inside the modal.
677
+ */
678
+ async createProject(opts: {
679
+ name: string;
680
+ description?: string | undefined;
681
+ directory: string;
682
+ starter?: string | undefined;
683
+ template?: string | undefined;
684
+ }) {
685
+ const res = await fetch("/api/v1/projects", {
686
+ method: "POST",
687
+ credentials: "include",
688
+ headers: { "Content-Type": "application/json" },
689
+ body: JSON.stringify({
690
+ name: opts.name,
691
+ description: opts.description,
692
+ starter: opts.starter,
693
+ }),
694
+ });
695
+ const created = await okJson<{ owner: string; name: string; defaultBranch: string }>(
696
+ res,
697
+ "Failed to create project",
698
+ );
699
+ return {
700
+ root: projectRootKey({
701
+ owner: created.owner,
702
+ repo: created.name,
703
+ branch: created.defaultBranch,
704
+ }),
705
+ config: { name: opts.name } as ProjectConfig,
706
+ };
707
+ },
708
+
709
+ // ─── Identity & Cloudflare publish surface ──────────────────────────────
710
+
711
+ async getUser() {
712
+ const res = await fetch("/api/v1/me", { credentials: "include" });
713
+ if (!res.ok) {
714
+ return null;
715
+ }
716
+ const me = (await res.json()) as {
717
+ user: { login: string; name: string | null; avatar_url: string | null } | null;
718
+ };
719
+ if (!me.user) {
720
+ return null;
721
+ }
722
+ return {
723
+ login: me.user.login,
724
+ ...(me.user.name ? { name: me.user.name } : {}),
725
+ ...(me.user.avatar_url ? { avatarUrl: me.user.avatar_url } : {}),
726
+ };
727
+ },
728
+
729
+ /** Open a PR from this session's branch (ProjectSession /git/pr). */
730
+ async createPullRequest(opts: { title: string; body?: string; head?: string; base?: string }) {
731
+ return okJson<{ url: string; number: number }>(
732
+ await postJson("/git/pr", opts),
733
+ "Failed to open pull request",
734
+ );
735
+ },
736
+
737
+ async cfConnection() {
738
+ return fetchCfConnection();
739
+ },
740
+
741
+ /**
742
+ * Hosted OAuth connect: open the broker flow in a popup and poll until the connection lands (or
743
+ * the popup closes / times out). Popup-blocked browsers fall back to a full-page redirect.
744
+ */
745
+ async cfConnect() {
746
+ if (typeof window === "undefined" || typeof location === "undefined") {
747
+ return null;
748
+ }
749
+ const popup = window.open("/api/v1/cf/connect", "cf-connect", "width=980,height=780");
750
+ if (!popup) {
751
+ location.assign("/api/v1/cf/connect");
752
+ return null;
753
+ }
754
+ const deadline = Date.now() + 180_000;
755
+ while (Date.now() < deadline) {
756
+ await new Promise((resolve) => {
757
+ setTimeout(resolve, 1500);
758
+ });
759
+ const connection = await fetchCfConnection();
760
+ if (connection) {
761
+ popup.close();
762
+ return connection;
763
+ }
764
+ if (popup.closed) {
765
+ return fetchCfConnection();
766
+ }
767
+ }
768
+ return null;
769
+ },
770
+
771
+ /** Allowlisted Cloudflare API passthrough (platform injects the OAuth token). */
772
+ async cfApi(apiPath: string, init?: { method?: string; body?: unknown }) {
773
+ const res = await fetch(`/api/v1/cf/proxy${apiPath}`, {
774
+ method: init?.method ?? "GET",
775
+ credentials: "include",
776
+ ...(init?.body === undefined
777
+ ? {}
778
+ : {
779
+ headers: { "Content-Type": "application/json" },
780
+ body: JSON.stringify(init.body),
781
+ }),
782
+ });
783
+ const envelope = (await res.json()) as {
784
+ success?: boolean;
785
+ result?: unknown;
786
+ errors?: { message: string }[];
787
+ error?: string;
788
+ };
789
+ if (!res.ok || envelope.success === false) {
790
+ const message =
791
+ envelope.errors?.map((e) => e.message).join("; ") ?? envelope.error ?? res.statusText;
792
+ throw new Error(`Cloudflare API: ${message}`);
793
+ }
794
+ return envelope.result ?? envelope;
795
+ },
796
+
797
+ // ─── AI (platform Workers AI proxy, StreamEvent SSE) ───────────────────
798
+
799
+ aiChatUrl() {
800
+ return "/api/v1/ai/chat";
801
+ },
802
+ };
803
+
804
+ return platform;
805
+ }