@jxsuite/studio 0.37.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/studio.js +77654 -76269
- package/dist/studio.js.map +128 -119
- package/package.json +44 -44
- package/src/account-status.ts +39 -0
- package/src/browse/browse.ts +10 -14
- package/src/canvas/iframe-host.ts +1 -1
- package/src/editor/context-menu.ts +1 -1
- package/src/editor/repeater-scope.ts +8 -13
- package/src/files/files.ts +3 -0
- package/src/format/format-host.ts +63 -1
- package/src/new-project/add-repo-modal.ts +183 -0
- package/src/new-project/new-project-modal.ts +22 -3
- package/src/page-params.ts +34 -8
- package/src/panels/ai-chat/chat-markdown.ts +2 -2
- package/src/panels/ai-panel.ts +61 -4
- package/src/panels/data-grid.ts +619 -0
- package/src/panels/signals-panel.ts +102 -437
- package/src/panels/statusbar.ts +1 -1
- package/src/panels/welcome-screen.ts +50 -0
- package/src/platform-errors.ts +30 -0
- package/src/platforms/cloud.ts +172 -89
- package/src/platforms/devserver.ts +172 -0
- package/src/services/context-resolver.ts +73 -0
- package/src/services/data-service.ts +155 -0
- package/src/services/monaco-setup.ts +75 -26
- package/src/settings/contributed-section.ts +406 -0
- package/src/settings/extension-sections.ts +145 -0
- package/src/settings/schema-field-ui.ts +4 -2
- package/src/settings/settings-modal.ts +101 -42
- package/src/site-context.ts +10 -1
- package/src/studio.ts +24 -0
- package/src/tabs/transact.ts +1 -1
- package/src/types.ts +120 -1
- package/src/ui/form-controls.ts +322 -0
- package/src/ui/progress-modal.ts +2 -2
- package/src/ui/schema-form.ts +524 -0
- package/src/utils/studio-utils.ts +4 -3
- package/src/settings/content-types-editor.ts +0 -599
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { html, render as litRender, nothing } from "lit-html";
|
|
8
|
+
import { getAccountStatus, needsAppInstall } from "../account-status";
|
|
9
|
+
import { platformSupportsAddRepo } from "../new-project/add-repo-modal";
|
|
8
10
|
import { getProjectList } from "../project-list";
|
|
9
11
|
import { clearRecentProjects, getRecentProjects, removeRecentProject } from "../recent-projects";
|
|
10
12
|
import { renderOnly } from "../store";
|
|
@@ -15,6 +17,7 @@ interface WelcomeCtx {
|
|
|
15
17
|
openRecentProject: (root: string) => void;
|
|
16
18
|
openNewProject: () => void;
|
|
17
19
|
cloneRepository: () => void;
|
|
20
|
+
addExistingRepo: () => void;
|
|
18
21
|
}
|
|
19
22
|
|
|
20
23
|
let _ctx: WelcomeCtx | null = null;
|
|
@@ -31,6 +34,7 @@ export function renderWelcome(host: HTMLElement) {
|
|
|
31
34
|
// Catalogue entries already in Recent stay in that section only.
|
|
32
35
|
const catalogue = getProjectList().filter((p) => !recent.some((r) => r.root === p.root));
|
|
33
36
|
const showClone = platformSupportsClone();
|
|
37
|
+
const showAddRepo = platformSupportsAddRepo();
|
|
34
38
|
|
|
35
39
|
litRender(
|
|
36
40
|
html`
|
|
@@ -85,8 +89,54 @@ export function renderWelcome(host: HTMLElement) {
|
|
|
85
89
|
Clone Git Repository...
|
|
86
90
|
</button>`
|
|
87
91
|
: nothing}
|
|
92
|
+
${showAddRepo
|
|
93
|
+
? html`<button class="welcome-action" @click=${() => ctx.addExistingRepo()}>
|
|
94
|
+
<svg
|
|
95
|
+
class="welcome-action-icon"
|
|
96
|
+
width="16"
|
|
97
|
+
height="16"
|
|
98
|
+
viewBox="0 0 16 16"
|
|
99
|
+
fill="currentColor"
|
|
100
|
+
>
|
|
101
|
+
<path
|
|
102
|
+
d="M2 2.5A2.5 2.5 0 0 1 4.5 0h8.75a.75.75 0 0 1 .75.75v12.5a.75.75 0 0 1-.75.75h-2.5a.75.75 0 0 1 0-1.5h1.75v-2h-8a1 1 0 0 0-.714 1.7.75.75 0 1 1-1.072 1.05A2.495 2.495 0 0 1 2 11.5Zm10.5-1h-8a1 1 0 0 0-1 1v6.708A2.5 2.5 0 0 1 4.5 9h8ZM5 12.25a.25.25 0 0 1 .25-.25h3.5a.25.25 0 0 1 .25.25v3.25a.25.25 0 0 1-.4.2l-1.45-1.087a.25.25 0 0 0-.3 0L5.4 15.7a.25.25 0 0 1-.4-.2Z"
|
|
103
|
+
/>
|
|
104
|
+
</svg>
|
|
105
|
+
Add Existing Repository...
|
|
106
|
+
</button>`
|
|
107
|
+
: nothing}
|
|
88
108
|
</div>
|
|
89
109
|
|
|
110
|
+
${needsAppInstall()
|
|
111
|
+
? html`
|
|
112
|
+
<div class="welcome-section">
|
|
113
|
+
<h2 class="welcome-section-title">Repository access</h2>
|
|
114
|
+
<a
|
|
115
|
+
class="welcome-action"
|
|
116
|
+
href=${getAccountStatus()?.appInstallUrl ?? "#"}
|
|
117
|
+
target="_blank"
|
|
118
|
+
rel="noreferrer"
|
|
119
|
+
>
|
|
120
|
+
<svg
|
|
121
|
+
class="welcome-action-icon"
|
|
122
|
+
width="16"
|
|
123
|
+
height="16"
|
|
124
|
+
viewBox="0 0 16 16"
|
|
125
|
+
fill="currentColor"
|
|
126
|
+
>
|
|
127
|
+
<path
|
|
128
|
+
d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z"
|
|
129
|
+
/>
|
|
130
|
+
</svg>
|
|
131
|
+
Install the Jx Suite GitHub App
|
|
132
|
+
</a>
|
|
133
|
+
<p class="welcome-install-note">
|
|
134
|
+
Grants repository access so you can create and open projects — choose “All
|
|
135
|
+
repositories”.
|
|
136
|
+
</p>
|
|
137
|
+
</div>
|
|
138
|
+
`
|
|
139
|
+
: nothing}
|
|
90
140
|
${catalogue.length > 0
|
|
91
141
|
? html`
|
|
92
142
|
<div class="welcome-section">
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured platform errors. Backend routes attach machine-readable fields to failures (e.g.
|
|
3
|
+
* project creation blocked by missing GitHub App installation access → `code:
|
|
4
|
+
* "needs_installation_access"` + `installUrl`); adapters preserve them on the thrown Error via
|
|
5
|
+
* `Object.assign`, and UI surfaces recover them here to render actions (an install link) instead of
|
|
6
|
+
* plain text.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface PlatformErrorInfo {
|
|
10
|
+
code?: string;
|
|
11
|
+
installUrl?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Machine-readable fields carried by a thrown platform error (empty object when none). */
|
|
15
|
+
export function platformErrorInfo(error: unknown): PlatformErrorInfo {
|
|
16
|
+
if (!error || typeof error !== "object") {
|
|
17
|
+
return {};
|
|
18
|
+
}
|
|
19
|
+
const { code, installUrl } = error as { code?: unknown; installUrl?: unknown };
|
|
20
|
+
return {
|
|
21
|
+
...(typeof code === "string" ? { code } : {}),
|
|
22
|
+
...(typeof installUrl === "string" ? { installUrl } : {}),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The GitHub-App install link when the error is the structured needs-installation 403. */
|
|
27
|
+
export function installUrlOf(error: unknown): string | null {
|
|
28
|
+
const info = platformErrorInfo(error);
|
|
29
|
+
return info.code === "needs_installation_access" && info.installUrl ? info.installUrl : null;
|
|
30
|
+
}
|
package/src/platforms/cloud.ts
CHANGED
|
@@ -11,15 +11,11 @@
|
|
|
11
11
|
* gitClone, resolveClass, component discovery, code services.
|
|
12
12
|
*/
|
|
13
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
14
|
import type { WsCollabConnection } from "@jxsuite/collab/client";
|
|
20
|
-
import type {
|
|
15
|
+
import type { ProjectConfig } from "@jxsuite/schema/types";
|
|
21
16
|
import type {
|
|
22
17
|
DirEntry,
|
|
18
|
+
ExtensionsInfo,
|
|
23
19
|
FsEvent,
|
|
24
20
|
GitBranchesResult,
|
|
25
21
|
GitLogEntry,
|
|
@@ -27,6 +23,7 @@ import type {
|
|
|
27
23
|
PackageInfo,
|
|
28
24
|
ProjectListEntry,
|
|
29
25
|
RenameResult,
|
|
26
|
+
RepoInfo,
|
|
30
27
|
StarterInfo,
|
|
31
28
|
StudioPlatform,
|
|
32
29
|
} from "../types";
|
|
@@ -74,47 +71,6 @@ async function okJson<T>(res: Response, fallback: string): Promise<T> {
|
|
|
74
71
|
return (await res.json()) as T;
|
|
75
72
|
}
|
|
76
73
|
|
|
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
74
|
/** Editor URL for a project session (mirrors the shell's route). */
|
|
119
75
|
export function editUrl(project: CloudProject): string {
|
|
120
76
|
return `/edit/${project.owner}/${project.repo}@${encodeURIComponent(project.branch)}`;
|
|
@@ -177,6 +133,8 @@ async function fetchCfConnection(): Promise<{
|
|
|
177
133
|
};
|
|
178
134
|
}
|
|
179
135
|
|
|
136
|
+
type CfConnectionInfo = Awaited<ReturnType<typeof fetchCfConnection>>;
|
|
137
|
+
|
|
180
138
|
/** Parse an "owner/repo@branch" root key; null when malformed. */
|
|
181
139
|
export function parseRootKey(root: string): CloudProject | null {
|
|
182
140
|
const match = /^([^/@]+)\/([^/@]+)@(.+)$/.exec(root);
|
|
@@ -247,8 +205,6 @@ export function createCloudPlatform(project: CloudProject | null): StudioPlatfor
|
|
|
247
205
|
}
|
|
248
206
|
}
|
|
249
207
|
|
|
250
|
-
let cachedConfig: ProjectConfig | null = null;
|
|
251
|
-
|
|
252
208
|
const platform: StudioPlatform = {
|
|
253
209
|
id: "cloud",
|
|
254
210
|
projectRoot: root,
|
|
@@ -415,34 +371,59 @@ export function createCloudPlatform(project: CloudProject | null): StudioPlatfor
|
|
|
415
371
|
};
|
|
416
372
|
},
|
|
417
373
|
|
|
418
|
-
// ─── Formats (
|
|
374
|
+
// ─── Formats (session backend registry, mirroring the dev-server seam) ─
|
|
419
375
|
|
|
376
|
+
/**
|
|
377
|
+
* The project's format registry, served by the session gateway (the dev server's `GET
|
|
378
|
+
* /__studio/formats` under this session's base path). Part-4 cleanup: the cloud ProjectSession
|
|
379
|
+
* does not serve the route yet, so this degrades to an empty registry (only .json documents
|
|
380
|
+
* open) until the backend lands it.
|
|
381
|
+
*/
|
|
420
382
|
async listFormats() {
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
} catch {
|
|
426
|
-
cachedConfig = null;
|
|
383
|
+
try {
|
|
384
|
+
const res = await api("/formats");
|
|
385
|
+
if (!res.ok) {
|
|
386
|
+
return [];
|
|
427
387
|
}
|
|
388
|
+
const body = (await res.json()) as { formats?: Record<string, unknown>[] };
|
|
389
|
+
return body.formats ?? [];
|
|
390
|
+
} catch {
|
|
391
|
+
return [];
|
|
428
392
|
}
|
|
429
|
-
return builtinFormats(cachedConfig);
|
|
430
393
|
},
|
|
431
394
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
return
|
|
395
|
+
/** The extensions payload riding beside `formats` on the same route. */
|
|
396
|
+
async listExtensions(): Promise<ExtensionsInfo[]> {
|
|
397
|
+
try {
|
|
398
|
+
const res = await api("/formats");
|
|
399
|
+
if (!res.ok) {
|
|
400
|
+
return [];
|
|
401
|
+
}
|
|
402
|
+
const body = (await res.json()) as { extensions?: ExtensionsInfo[] };
|
|
403
|
+
return body.extensions ?? [];
|
|
404
|
+
} catch {
|
|
405
|
+
return [];
|
|
441
406
|
}
|
|
442
|
-
|
|
443
|
-
|
|
407
|
+
},
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Invoke a format capability (parse/serialize) on the session backend — the dev server's `POST
|
|
411
|
+
* /__studio/format` seam. In-page execution left with the core → @jxsuite/parser dependency, so
|
|
412
|
+
* backends without the route surface the error below instead of silently mangling format
|
|
413
|
+
* documents.
|
|
414
|
+
*/
|
|
415
|
+
async formatAction(payload: Record<string, unknown>) {
|
|
416
|
+
const res = await postJson("/format", payload);
|
|
417
|
+
if (!res.ok) {
|
|
418
|
+
throw new Error(
|
|
419
|
+
await errorMessage(
|
|
420
|
+
res,
|
|
421
|
+
"This cloud session cannot run format actions yet (the backend serves no format route)",
|
|
422
|
+
),
|
|
423
|
+
);
|
|
444
424
|
}
|
|
445
|
-
|
|
425
|
+
const data = (await res.json()) as { result?: unknown };
|
|
426
|
+
return data.result;
|
|
446
427
|
},
|
|
447
428
|
|
|
448
429
|
// ─── Components / code services (cloud: static-only posture) ──────────
|
|
@@ -692,10 +673,18 @@ export function createCloudPlatform(project: CloudProject | null): StudioPlatfor
|
|
|
692
673
|
starter: opts.starter,
|
|
693
674
|
}),
|
|
694
675
|
});
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
676
|
+
if (!res.ok) {
|
|
677
|
+
// Preserve the structured 403 (needs_installation_access + installUrl) so the New
|
|
678
|
+
// Project modal can render an install link instead of flattened text.
|
|
679
|
+
const body = (await res.json().catch(() => null)) as
|
|
680
|
+
| (ErrorBody & { installUrl?: string })
|
|
681
|
+
| null;
|
|
682
|
+
throw Object.assign(new Error(body?.error ?? "Failed to create project"), {
|
|
683
|
+
...(body?.code ? { code: body.code } : {}),
|
|
684
|
+
...(body?.installUrl ? { installUrl: body.installUrl } : {}),
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
const created = (await res.json()) as { owner: string; name: string; defaultBranch: string };
|
|
699
688
|
return {
|
|
700
689
|
root: projectRootKey({
|
|
701
690
|
owner: created.owner,
|
|
@@ -726,6 +715,61 @@ export function createCloudPlatform(project: CloudProject | null): StudioPlatfor
|
|
|
726
715
|
};
|
|
727
716
|
},
|
|
728
717
|
|
|
718
|
+
/** Repositories reachable through the user's Jx Suite App installations (user + org). */
|
|
719
|
+
async listRepos(): Promise<RepoInfo[]> {
|
|
720
|
+
const res = await fetch("/api/v1/repos", { credentials: "include" });
|
|
721
|
+
if (!res.ok) {
|
|
722
|
+
throw new Error(await errorMessage(res, "Failed to list repositories"));
|
|
723
|
+
}
|
|
724
|
+
const entries = (await res.json()) as (RepoInfo & { repoId: number })[];
|
|
725
|
+
return entries.map((r) => ({
|
|
726
|
+
owner: r.owner,
|
|
727
|
+
name: r.name,
|
|
728
|
+
fullName: r.fullName,
|
|
729
|
+
private: r.private,
|
|
730
|
+
defaultBranch: r.defaultBranch,
|
|
731
|
+
permission: r.permission,
|
|
732
|
+
isJxProject: r.isJxProject,
|
|
733
|
+
}));
|
|
734
|
+
},
|
|
735
|
+
|
|
736
|
+
/** Adopt an existing repo as a Jx project; resolves to its catalogue root key. */
|
|
737
|
+
async importProject(opts: { owner: string; name: string }) {
|
|
738
|
+
const res = await fetch("/api/v1/projects/import", {
|
|
739
|
+
method: "POST",
|
|
740
|
+
credentials: "include",
|
|
741
|
+
headers: { "Content-Type": "application/json" },
|
|
742
|
+
body: JSON.stringify(opts),
|
|
743
|
+
});
|
|
744
|
+
const imported = await okJson<{ owner: string; name: string; defaultBranch: string }>(
|
|
745
|
+
res,
|
|
746
|
+
"Failed to import repository",
|
|
747
|
+
);
|
|
748
|
+
return {
|
|
749
|
+
root: projectRootKey({
|
|
750
|
+
owner: imported.owner,
|
|
751
|
+
repo: imported.name,
|
|
752
|
+
branch: imported.defaultBranch,
|
|
753
|
+
}),
|
|
754
|
+
};
|
|
755
|
+
},
|
|
756
|
+
|
|
757
|
+
/** GitHub-App installation coverage from /me — powers the welcome install prompt. */
|
|
758
|
+
async getAccountStatus() {
|
|
759
|
+
const res = await fetch("/api/v1/me", { credentials: "include" });
|
|
760
|
+
if (!res.ok) {
|
|
761
|
+
return null;
|
|
762
|
+
}
|
|
763
|
+
const me = (await res.json()) as {
|
|
764
|
+
installations?: { id: number; account: string | null }[];
|
|
765
|
+
appInstallUrl?: string;
|
|
766
|
+
};
|
|
767
|
+
return {
|
|
768
|
+
installations: me.installations ?? [],
|
|
769
|
+
...(me.appInstallUrl ? { appInstallUrl: me.appInstallUrl } : {}),
|
|
770
|
+
};
|
|
771
|
+
},
|
|
772
|
+
|
|
729
773
|
/** Open a PR from this session's branch (ProjectSession /git/pr). */
|
|
730
774
|
async createPullRequest(opts: { title: string; body?: string; head?: string; base?: string }) {
|
|
731
775
|
return okJson<{ url: string; number: number }>(
|
|
@@ -739,8 +783,11 @@ export function createCloudPlatform(project: CloudProject | null): StudioPlatfor
|
|
|
739
783
|
},
|
|
740
784
|
|
|
741
785
|
/**
|
|
742
|
-
* Hosted OAuth connect: open the broker flow in a popup
|
|
743
|
-
*
|
|
786
|
+
* Hosted OAuth connect: open the broker flow in a popup. The home shell relays the callback's
|
|
787
|
+
* result back via postMessage {source: "jx-cf"} and closes the popup, so success resolves and
|
|
788
|
+
* OAuth errors (denial, invalid_scope misregistration) REJECT with the real reason instead of
|
|
789
|
+
* timing out. A 1.5s poll remains as fallback for older shells / blocked message delivery, and
|
|
790
|
+
* popup-blocked browsers fall back to a full-page redirect.
|
|
744
791
|
*/
|
|
745
792
|
async cfConnect() {
|
|
746
793
|
if (typeof window === "undefined" || typeof location === "undefined") {
|
|
@@ -752,20 +799,56 @@ export function createCloudPlatform(project: CloudProject | null): StudioPlatfor
|
|
|
752
799
|
return null;
|
|
753
800
|
}
|
|
754
801
|
const deadline = Date.now() + 180_000;
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
}
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
802
|
+
return new Promise<CfConnectionInfo>((resolve, reject) => {
|
|
803
|
+
let timer = 0;
|
|
804
|
+
const cleanup = () => {
|
|
805
|
+
window.removeEventListener("message", onMessage);
|
|
806
|
+
window.clearTimeout(timer);
|
|
807
|
+
if (!popup.closed) {
|
|
808
|
+
popup.close();
|
|
809
|
+
}
|
|
810
|
+
};
|
|
811
|
+
const settle = (connection: CfConnectionInfo) => {
|
|
812
|
+
cleanup();
|
|
813
|
+
resolve(connection);
|
|
814
|
+
};
|
|
815
|
+
const fail = (reason: string) => {
|
|
816
|
+
cleanup();
|
|
817
|
+
reject(new Error(reason));
|
|
818
|
+
};
|
|
819
|
+
const onMessage = (event: MessageEvent) => {
|
|
820
|
+
if (event.origin !== location.origin) {
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
const data = event.data as { source?: string; status?: string; reason?: string | null };
|
|
824
|
+
if (!data || data.source !== "jx-cf") {
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
if (data.status === "error") {
|
|
828
|
+
fail(data.reason ?? "Cloudflare authorization failed");
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
void fetchCfConnection().then(settle);
|
|
832
|
+
};
|
|
833
|
+
window.addEventListener("message", onMessage);
|
|
834
|
+
const poll = async () => {
|
|
835
|
+
if (Date.now() > deadline) {
|
|
836
|
+
settle(null);
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
const connection = await fetchCfConnection();
|
|
840
|
+
if (connection) {
|
|
841
|
+
settle(connection);
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
if (popup.closed) {
|
|
845
|
+
settle(await fetchCfConnection());
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
timer = window.setTimeout(() => void poll(), 1500);
|
|
849
|
+
};
|
|
850
|
+
timer = window.setTimeout(() => void poll(), 1500);
|
|
851
|
+
});
|
|
769
852
|
},
|
|
770
853
|
|
|
771
854
|
/** Allowlisted Cloudflare API passthrough (platform injects the OAuth token). */
|
|
@@ -12,11 +12,23 @@ import { streamImport } from "../services/import-client";
|
|
|
12
12
|
import type { WsCollabConnection } from "@jxsuite/collab/client";
|
|
13
13
|
import type { ProjectConfig } from "@jxsuite/schema/types";
|
|
14
14
|
import type {
|
|
15
|
+
DataConnectionsResponse,
|
|
16
|
+
DataConnectionTestResult,
|
|
17
|
+
DataPushResult,
|
|
18
|
+
DataRowDelete,
|
|
19
|
+
DataRowInsert,
|
|
20
|
+
DataRowsQuery,
|
|
21
|
+
DataRowsResult,
|
|
22
|
+
DataRowUpdate,
|
|
15
23
|
DirEntry,
|
|
24
|
+
ExtensionsInfo,
|
|
16
25
|
FsEvent,
|
|
17
26
|
ImportProgressEvent,
|
|
18
27
|
ImportSiteOptions,
|
|
28
|
+
ProjectSchemasResponse,
|
|
19
29
|
RenameResult,
|
|
30
|
+
SecretsSetRequest,
|
|
31
|
+
SecretsSetResponse,
|
|
20
32
|
StarterInfo,
|
|
21
33
|
} from "../types";
|
|
22
34
|
|
|
@@ -613,6 +625,166 @@ export function createDevServerPlatform() {
|
|
|
613
625
|
return body.formats ?? [];
|
|
614
626
|
},
|
|
615
627
|
|
|
628
|
+
/** The extensions payload riding beside `formats` on the same route. */
|
|
629
|
+
async listExtensions(): Promise<ExtensionsInfo[]> {
|
|
630
|
+
const res = await fetch(`/__studio/formats?dir=${encodeURIComponent(serverPath("."))}`);
|
|
631
|
+
if (!res.ok) {
|
|
632
|
+
return [];
|
|
633
|
+
}
|
|
634
|
+
const body = await readJson<{ extensions?: ExtensionsInfo[] }>(res);
|
|
635
|
+
return body.extensions ?? [];
|
|
636
|
+
},
|
|
637
|
+
|
|
638
|
+
/** Pre-bundled per-project entry schemas for Monaco registration (empty when unavailable). */
|
|
639
|
+
async fetchProjectSchemas(): Promise<ProjectSchemasResponse> {
|
|
640
|
+
const res = await fetch(
|
|
641
|
+
`/__studio/project-schemas?dir=${encodeURIComponent(serverPath("."))}`,
|
|
642
|
+
);
|
|
643
|
+
if (!res.ok) {
|
|
644
|
+
return {};
|
|
645
|
+
}
|
|
646
|
+
return await readJson<ProjectSchemasResponse>(res);
|
|
647
|
+
},
|
|
648
|
+
|
|
649
|
+
// ─── Data surface + secrets (owner console over /__studio/data/* + /__studio/secrets) ──
|
|
650
|
+
|
|
651
|
+
/** Connector connections with configured state, table names, and provider metadata. */
|
|
652
|
+
async dataConnections(): Promise<DataConnectionsResponse> {
|
|
653
|
+
const res = await fetch(
|
|
654
|
+
`/__studio/data/connections?dir=${encodeURIComponent(serverPath("."))}`,
|
|
655
|
+
);
|
|
656
|
+
if (!res.ok) {
|
|
657
|
+
const data = await readJson<ErrorBody>(res);
|
|
658
|
+
throw new Error(data.error || "Failed to list connections");
|
|
659
|
+
}
|
|
660
|
+
return await readJson<DataConnectionsResponse>(res);
|
|
661
|
+
},
|
|
662
|
+
|
|
663
|
+
/** Probe one connection through the backend's connector registry. */
|
|
664
|
+
async dataConnectionTest(connection: string): Promise<DataConnectionTestResult> {
|
|
665
|
+
const res = await fetch(
|
|
666
|
+
`/__studio/data/connections/test?dir=${encodeURIComponent(serverPath("."))}`,
|
|
667
|
+
{
|
|
668
|
+
body: JSON.stringify({ connection }),
|
|
669
|
+
headers: { "Content-Type": "application/json" },
|
|
670
|
+
method: "POST",
|
|
671
|
+
},
|
|
672
|
+
);
|
|
673
|
+
const data = await readJson<DataConnectionTestResult & ErrorBody>(res);
|
|
674
|
+
if (!res.ok) {
|
|
675
|
+
throw new Error(data.error || "Connection test failed");
|
|
676
|
+
}
|
|
677
|
+
return data;
|
|
678
|
+
},
|
|
679
|
+
|
|
680
|
+
/** Additive schema push; `dryRun` compiles the plan without applying it. */
|
|
681
|
+
async dataPush(opts?: { connection?: string; dryRun?: boolean }): Promise<DataPushResult> {
|
|
682
|
+
const res = await fetch(`/__studio/data/push?dir=${encodeURIComponent(serverPath("."))}`, {
|
|
683
|
+
body: JSON.stringify(opts ?? {}),
|
|
684
|
+
headers: { "Content-Type": "application/json" },
|
|
685
|
+
method: "POST",
|
|
686
|
+
});
|
|
687
|
+
const data = await readJson<DataPushResult & ErrorBody>(res);
|
|
688
|
+
if (!res.ok) {
|
|
689
|
+
throw new Error(data.error || "Schema push failed");
|
|
690
|
+
}
|
|
691
|
+
return data;
|
|
692
|
+
},
|
|
693
|
+
|
|
694
|
+
/** Page a table's rows with introspected column metadata. */
|
|
695
|
+
async dataRows(query: DataRowsQuery): Promise<DataRowsResult> {
|
|
696
|
+
const params = new URLSearchParams({ dir: serverPath("."), table: query.table });
|
|
697
|
+
if (query.connection) {
|
|
698
|
+
params.set("connection", query.connection);
|
|
699
|
+
}
|
|
700
|
+
if (query.limit !== undefined) {
|
|
701
|
+
params.set("limit", String(query.limit));
|
|
702
|
+
}
|
|
703
|
+
if (query.offset !== undefined) {
|
|
704
|
+
params.set("offset", String(query.offset));
|
|
705
|
+
}
|
|
706
|
+
if (query.orderBy !== undefined) {
|
|
707
|
+
params.set("orderBy", query.orderBy);
|
|
708
|
+
}
|
|
709
|
+
if (query.dir !== undefined) {
|
|
710
|
+
params.set("dir", query.dir);
|
|
711
|
+
}
|
|
712
|
+
const res = await fetch(`/__studio/data/rows?${params}`);
|
|
713
|
+
const data = await readJson<DataRowsResult & ErrorBody>(res);
|
|
714
|
+
if (!res.ok) {
|
|
715
|
+
throw new Error(data.error || "Failed to load rows");
|
|
716
|
+
}
|
|
717
|
+
return data;
|
|
718
|
+
},
|
|
719
|
+
|
|
720
|
+
async dataInsertRow(req: DataRowInsert): Promise<{ row: Record<string, unknown> }> {
|
|
721
|
+
const res = await fetch(`/__studio/data/rows?dir=${encodeURIComponent(serverPath("."))}`, {
|
|
722
|
+
body: JSON.stringify(req),
|
|
723
|
+
headers: { "Content-Type": "application/json" },
|
|
724
|
+
method: "POST",
|
|
725
|
+
});
|
|
726
|
+
const data = await readJson<{ row: Record<string, unknown> } & ErrorBody>(res);
|
|
727
|
+
if (!res.ok) {
|
|
728
|
+
throw new Error(data.error || "Insert failed");
|
|
729
|
+
}
|
|
730
|
+
return data;
|
|
731
|
+
},
|
|
732
|
+
|
|
733
|
+
async dataUpdateRow(req: DataRowUpdate): Promise<{ row: Record<string, unknown> }> {
|
|
734
|
+
const res = await fetch(`/__studio/data/rows?dir=${encodeURIComponent(serverPath("."))}`, {
|
|
735
|
+
body: JSON.stringify(req),
|
|
736
|
+
headers: { "Content-Type": "application/json" },
|
|
737
|
+
method: "PUT",
|
|
738
|
+
});
|
|
739
|
+
const data = await readJson<{ row: Record<string, unknown> } & ErrorBody>(res);
|
|
740
|
+
if (!res.ok) {
|
|
741
|
+
throw new Error(data.error || "Update failed");
|
|
742
|
+
}
|
|
743
|
+
return data;
|
|
744
|
+
},
|
|
745
|
+
|
|
746
|
+
async dataDeleteRow(req: DataRowDelete): Promise<{ ok: boolean }> {
|
|
747
|
+
const params = new URLSearchParams({
|
|
748
|
+
dir: serverPath("."),
|
|
749
|
+
pk: String(req.pk),
|
|
750
|
+
table: req.table,
|
|
751
|
+
});
|
|
752
|
+
if (req.connection) {
|
|
753
|
+
params.set("connection", req.connection);
|
|
754
|
+
}
|
|
755
|
+
const res = await fetch(`/__studio/data/rows?${params}`, { method: "DELETE" });
|
|
756
|
+
const data = await readJson<{ ok: boolean } & ErrorBody>(res);
|
|
757
|
+
if (!res.ok) {
|
|
758
|
+
throw new Error(data.error || "Delete failed");
|
|
759
|
+
}
|
|
760
|
+
return data;
|
|
761
|
+
},
|
|
762
|
+
|
|
763
|
+
/** Configured secret env-var NAMES — never values. */
|
|
764
|
+
async listSecrets(): Promise<string[]> {
|
|
765
|
+
const res = await fetch(`/__studio/secrets?dir=${encodeURIComponent(serverPath("."))}`);
|
|
766
|
+
if (!res.ok) {
|
|
767
|
+
const data = await readJson<ErrorBody>(res);
|
|
768
|
+
throw new Error(data.error || "Failed to list secrets");
|
|
769
|
+
}
|
|
770
|
+
const data = await readJson<{ names: string[] }>(res);
|
|
771
|
+
return data.names;
|
|
772
|
+
},
|
|
773
|
+
|
|
774
|
+
/** Write/remove secrets in the dev server's .dev.vars; names-only response. */
|
|
775
|
+
async setSecrets(req: SecretsSetRequest): Promise<SecretsSetResponse> {
|
|
776
|
+
const res = await fetch(`/__studio/secrets?dir=${encodeURIComponent(serverPath("."))}`, {
|
|
777
|
+
body: JSON.stringify(req),
|
|
778
|
+
headers: { "Content-Type": "application/json" },
|
|
779
|
+
method: "PUT",
|
|
780
|
+
});
|
|
781
|
+
const data = await readJson<SecretsSetResponse & ErrorBody>(res);
|
|
782
|
+
if (!res.ok) {
|
|
783
|
+
throw new Error(data.error || "Failed to write secrets");
|
|
784
|
+
}
|
|
785
|
+
return data;
|
|
786
|
+
},
|
|
787
|
+
|
|
616
788
|
/**
|
|
617
789
|
* Invoke a format capability (parse/serialize) server-side.
|
|
618
790
|
*
|