@jxsuite/studio 4.0.0 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunks/{2020-xp8vnwma.js → 2020-n6tfwnda.js} +2 -2
- package/dist/chunks/{Overlay.dev-723bv0df.js → Overlay.dev-etqjwyf9.js} +4 -4
- package/dist/chunks/cf-account-picker-en86v7x7.js +163 -0
- package/dist/chunks/{content-source-t88vb2dr.js → content-source-48xg86wj.js} +17 -17
- package/dist/chunks/{convert-to-repeater-dhw1nrz9.js → convert-to-repeater-43tfn7cs.js} +24 -24
- package/dist/chunks/{entry-commands-6z6bhxg0.js → entry-commands-e63f1xc0.js} +20 -20
- package/dist/chunks/{extension-sections-3ydgjbdy.js → extension-sections-txv08xxg.js} +23 -23
- package/dist/chunks/{file-ops-rskmhxyq.js → file-ops-wbh4hhsx.js} +16 -16
- package/dist/chunks/{files-0cratsr0.js → files-3kfkwhk4.js} +25 -24
- package/dist/chunks/{focus-trap.esm-ggawf1hy.js → focus-trap.esm-9t2drp0g.js} +6 -5
- package/dist/chunks/{format-host-tgcmfs4p.js → format-host-w16q81tf.js} +2 -2
- package/dist/chunks/fs-events-hrrg328g.js +1 -1
- package/dist/chunks/{git-panel-0yywyk76.js → git-panel-532whr1y.js} +16 -15
- package/dist/chunks/{index-nj44jpzj.js → index-wcnt3r9g.js} +2 -2
- package/dist/chunks/{jx-validate-8q2g05x1.js → jx-validate-8er6gbxy.js} +3 -3
- package/dist/chunks/{layers-panel-z3tqe5cd.js → layers-panel-07hm7g5j.js} +19 -19
- package/dist/chunks/{monaco-setup-rws7k4br.js → monaco-setup-vx9bhp1d.js} +2 -2
- package/dist/chunks/{publish-panel-54jvhskq.js → publish-panel-v9st96ja.js} +11 -10
- package/dist/chunks/{quick-search-cgfmjwq5.js → quick-search-zccjp6gv.js} +26 -25
- package/dist/chunks/{right-panel-cqp8bc4v.js → right-panel-vm3b6kwy.js} +17 -17
- package/dist/chunks/{sp-overlay.dev-yztmtrs1.js → sp-overlay.dev-qgvhnxf4.js} +5 -5
- package/dist/chunks/{sp-tooltip.dev-xcvpnqr0.js → sp-tooltip.dev-4sastdnw.js} +3 -3
- package/dist/chunks/{studio-txwkm6p9.js → studio-0bq6eh7v.js} +2 -2
- package/dist/chunks/studio-1ngbejft.js +181 -0
- package/dist/chunks/{studio-gv8vvshc.js → studio-1wagb550.js} +3 -3
- package/dist/chunks/{studio-kjjsjak4.js → studio-2b946z26.js} +58 -57
- package/dist/chunks/{studio-4pqqxbrt.js → studio-3bqpr0zh.js} +16 -16
- package/dist/chunks/{studio-v38hmvx1.js → studio-4b5z1x43.js} +42 -8
- package/dist/chunks/{studio-9c4k4vhv.js → studio-4n2b5gzb.js} +4 -4
- package/dist/chunks/{studio-p8j86wag.js → studio-4r1pv6zf.js} +2 -2
- package/dist/chunks/{studio-mmv72a27.js → studio-5fx3g9jg.js} +29 -48
- package/dist/chunks/{studio-5rywrkxz.js → studio-5myknm0a.js} +42 -42
- package/dist/chunks/{studio-sf7zjgv1.js → studio-6yw4y3my.js} +3 -3
- package/dist/chunks/{studio-pdfjntbk.js → studio-950k0dx6.js} +35 -35
- package/dist/chunks/{studio-eenfkt4e.js → studio-ahn3pdgx.js} +5 -5
- package/dist/chunks/{studio-eesa1myv.js → studio-b55y6ewx.js} +9 -9
- package/dist/chunks/{studio-cb12yz9w.js → studio-cmb03bg5.js} +99 -1264
- package/dist/chunks/studio-dm19q43r.js +195 -0
- package/dist/chunks/{studio-ahjfdy6e.js → studio-ef49pnzx.js} +6 -6
- package/dist/chunks/{studio-wx5609xd.js → studio-ew0djmhg.js} +10 -10
- package/dist/chunks/{studio-9r6j5ev1.js → studio-f3wqjtk5.js} +4 -3
- package/dist/chunks/{studio-8swrjbny.js → studio-fbf92ckf.js} +14 -14
- package/dist/chunks/{studio-4q0wznzf.js → studio-gcye7qka.js} +442 -126
- package/dist/chunks/{studio-ex8zyp98.js → studio-gfgb8pe6.js} +23 -184
- package/dist/chunks/{studio-r1r7afxx.js → studio-jc994dnr.js} +24 -24
- package/dist/chunks/{studio-yxrkyv8a.js → studio-jkftxyvz.js} +52 -3416
- package/dist/chunks/{studio-hbtp0eh1.js → studio-msd847zn.js} +13 -13
- package/dist/chunks/{studio-kyk2v92a.js → studio-mtfnaqs9.js} +14 -14
- package/dist/chunks/{studio-vadzgkrz.js → studio-n6j9t10q.js} +2 -2
- package/dist/chunks/{studio-68n67094.js → studio-q9jd1a60.js} +4 -4
- package/dist/chunks/studio-qcy05dp8.js +1743 -0
- package/dist/chunks/{studio-htsf0w6m.js → studio-qjy2z9bv.js} +12 -30
- package/dist/chunks/{studio-2zkz8evn.js → studio-trxrabqf.js} +12 -12
- package/dist/chunks/{studio-5q69gfag.js → studio-x4qkdat5.js} +12 -11
- package/dist/chunks/studio-x813zp48.js +195 -0
- package/dist/chunks/{studio-1hbqnt19.js → studio-zqazsct2.js} +29 -29
- package/dist/chunks/{workspace-xpres40v.js → workspace-fjx52ryv.js} +4 -4
- package/dist/iframe-entry.js +34 -33
- package/dist/manifest.json +7 -0
- package/dist/studio.js +534 -546
- package/favicon.ico +0 -0
- package/index.html +1 -0
- package/package.json +15 -14
- package/src/about/about-modal.ts +4 -3
- package/src/files/files.ts +6 -4
- package/src/files/media-paths.ts +13 -42
- package/src/files/media-usage.ts +41 -131
- package/src/hosting/document.ts +2 -0
- package/src/hosting/layout.ts +8 -0
- package/src/new-project/import-tab.ts +37 -15
- package/src/new-project/location-fields.ts +8 -5
- package/src/panels/ai-chat/composer.ts +10 -0
- package/src/platforms/cloud.ts +241 -70
- package/src/publish/publish-panel.ts +62 -2
- package/src/services/ai-import-tools.ts +50 -12
- package/src/services/ai-models.ts +108 -1
- package/src/services/context-manager.ts +12 -1
- package/src/services/tool-executor.ts +11 -0
- package/src/settings/preferences-accounts.ts +193 -12
- package/src/settings/preferences-dialog.ts +75 -18
- package/src/types.ts +27 -3
- package/src/ui/ai-managed-connect.ts +62 -13
- package/src/ui/ai-model-picker.ts +29 -2
- package/src/ui/cf-account-picker.ts +200 -0
- package/styles/shell.css +36 -0
- package/dist/chunks/studio-bw5dz6km.js +0 -608
package/src/platforms/cloud.ts
CHANGED
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
* `/api/v1/ai/chat`. The session is pre-bound to one (repo, branch) — the shell picks the project
|
|
7
7
|
* before Studio boots — while project-less mode (null) powers the /studio welcome screen.
|
|
8
8
|
*
|
|
9
|
-
* Cloud omissions (Studio degrades per @jxsuite/protocol's route table): pickDirectory
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* Cloud omissions (Studio degrades per @jxsuite/protocol's route table): pickDirectory, package
|
|
10
|
+
* install/outdated/set-versions (package ops are manifest-only edits), multi-window, gitClone,
|
|
11
|
+
* resolveClass, component discovery, code services.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { negotiateCollab } from "@jxsuite/collab/negotiate";
|
|
@@ -16,8 +16,12 @@ import type { CollabNegotiation } from "@jxsuite/collab/negotiate";
|
|
|
16
16
|
import type { WsCollabConnection } from "@jxsuite/collab/client";
|
|
17
17
|
import type { ProjectConfig } from "@jxsuite/schema/types";
|
|
18
18
|
import { componentMetaFrom } from "@jxsuite/schema/component-meta";
|
|
19
|
+
import { streamImport } from "../services/import-client";
|
|
19
20
|
import type {
|
|
20
21
|
AccountStatus,
|
|
22
|
+
CfAccountSummary,
|
|
23
|
+
CfConnection,
|
|
24
|
+
CfConnectOutcome,
|
|
21
25
|
ComponentMeta,
|
|
22
26
|
CreateProjectDestination,
|
|
23
27
|
DirEntry,
|
|
@@ -26,6 +30,9 @@ import type {
|
|
|
26
30
|
GitBranchesResult,
|
|
27
31
|
GitLogEntry,
|
|
28
32
|
GitStatusResult,
|
|
33
|
+
ImportProgressEvent,
|
|
34
|
+
ImportReadyEvent,
|
|
35
|
+
ImportSiteOptions,
|
|
29
36
|
PackageInfo,
|
|
30
37
|
ProjectListEntry,
|
|
31
38
|
SiteBuildResult,
|
|
@@ -124,12 +131,16 @@ export function projectRootKey(project: CloudProject): string {
|
|
|
124
131
|
return `${project.owner}/${project.repo}@${project.branch}`;
|
|
125
132
|
}
|
|
126
133
|
|
|
127
|
-
/**
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
134
|
+
/**
|
|
135
|
+
* Current hosted Cloudflare connection; null when none is brokered yet.
|
|
136
|
+
*
|
|
137
|
+
* A LAPSED row is not a healthy one. This reader used to answer `{connected: true}` for any body
|
|
138
|
+
* whose `connected` flag was set and drop `needsReconnect` on the floor, so a row whose grant had
|
|
139
|
+
* expired came back indistinguishable from one that works — which is how `cfConnect`'s poll came to
|
|
140
|
+
* close the popup the moment it opened, over a row from a previous session, while the user was
|
|
141
|
+
* still on Cloudflare's login page. Every diagnostic the broker sends now passes straight through.
|
|
142
|
+
*/
|
|
143
|
+
async function fetchCfConnection(): Promise<CfConnection | null> {
|
|
133
144
|
const res = await fetch("/api/v1/cf/connection", { credentials: "include" });
|
|
134
145
|
if (!res.ok) {
|
|
135
146
|
return null;
|
|
@@ -138,7 +149,15 @@ async function fetchCfConnection(): Promise<{
|
|
|
138
149
|
connected: boolean;
|
|
139
150
|
accountId?: string | null;
|
|
140
151
|
accountName?: string | null;
|
|
152
|
+
needsReconnect?: boolean;
|
|
153
|
+
needsAccount?: boolean;
|
|
154
|
+
code?: CfConnection["code"];
|
|
155
|
+
reason?: string;
|
|
156
|
+
hasRefreshToken?: boolean;
|
|
157
|
+
expiresAt?: number;
|
|
141
158
|
};
|
|
159
|
+
/* Cloud never emits {connected: false} to the PAL — no brokered row at all is `null`, and a row
|
|
160
|
+
that cannot be used arrives as connected + needsReconnect. See CfConnection. */
|
|
142
161
|
if (!body.connected) {
|
|
143
162
|
return null;
|
|
144
163
|
}
|
|
@@ -146,10 +165,131 @@ async function fetchCfConnection(): Promise<{
|
|
|
146
165
|
connected: true,
|
|
147
166
|
...(body.accountId ? { accountId: body.accountId } : {}),
|
|
148
167
|
...(body.accountName ? { accountName: body.accountName } : {}),
|
|
168
|
+
...(body.needsReconnect ? { needsReconnect: true } : {}),
|
|
169
|
+
...(body.needsAccount ? { needsAccount: true } : {}),
|
|
170
|
+
...(body.code ? { code: body.code } : {}),
|
|
171
|
+
...(body.reason ? { reason: body.reason } : {}),
|
|
172
|
+
...(body.hasRefreshToken === undefined ? {} : { hasRefreshToken: body.hasRefreshToken }),
|
|
173
|
+
...(body.expiresAt === undefined ? {} : { expiresAt: body.expiresAt }),
|
|
149
174
|
};
|
|
150
175
|
}
|
|
151
176
|
|
|
152
|
-
|
|
177
|
+
interface CfConnectFlow {
|
|
178
|
+
popup: Window | null;
|
|
179
|
+
promise: Promise<CfConnectOutcome | null>;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The connect flow currently running, or null.
|
|
184
|
+
*
|
|
185
|
+
* Module-level rather than per-platform because the popup's TARGET NAME ("cf-connect") is global to
|
|
186
|
+
* the browsing context: a second `window.open` on that name re-uses the first flow's popup, and the
|
|
187
|
+
* first flow's cleanup then closes it out from under the second — the user watches their half-typed
|
|
188
|
+
* Cloudflare login vanish. Joining the in-flight promise is the only answer that leaves both
|
|
189
|
+
* callers correct.
|
|
190
|
+
*/
|
|
191
|
+
let cfConnectFlow: CfConnectFlow | null = null;
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Drive one hosted OAuth connect to a {@link CfConnectOutcome}. See `cfConnect` for the semantics;
|
|
195
|
+
* this lives at module scope so the single-flight handle can too.
|
|
196
|
+
*/
|
|
197
|
+
async function runCfConnect(handle: CfConnectFlow): Promise<CfConnectOutcome | null> {
|
|
198
|
+
/* The baseline is read BEFORE the popup opens, and it is what keeps the poll honest: a row that
|
|
199
|
+
was already healthy proves nothing about THIS flow, so the poll must not settle on one. Reading
|
|
200
|
+
it after the popup opened would race the callback and could capture the new row as "old". */
|
|
201
|
+
const baseline = await fetchCfConnection().catch(() => null);
|
|
202
|
+
const healthyBaseline = Boolean(baseline?.connected && !baseline.needsReconnect);
|
|
203
|
+
const popup = window.open("/api/v1/cf/connect", "cf-connect", "width=980,height=780");
|
|
204
|
+
if (!popup) {
|
|
205
|
+
/* Popup blocked: the whole page is now navigating to the broker. Not a failure — the caller
|
|
206
|
+
must render nothing at all rather than an error it will never get to show. */
|
|
207
|
+
location.assign("/api/v1/cf/connect");
|
|
208
|
+
return { status: "redirect" };
|
|
209
|
+
}
|
|
210
|
+
handle.popup = popup;
|
|
211
|
+
const deadline = Date.now() + 180_000;
|
|
212
|
+
return new Promise<CfConnectOutcome>((resolve, reject) => {
|
|
213
|
+
let timer = 0;
|
|
214
|
+
const cleanup = () => {
|
|
215
|
+
window.removeEventListener("message", onMessage);
|
|
216
|
+
window.clearTimeout(timer);
|
|
217
|
+
if (!popup.closed) {
|
|
218
|
+
popup.close();
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
const settle = (outcome: CfConnectOutcome) => {
|
|
222
|
+
cleanup();
|
|
223
|
+
resolve(outcome);
|
|
224
|
+
};
|
|
225
|
+
const fail = (reason: string) => {
|
|
226
|
+
cleanup();
|
|
227
|
+
reject(new Error(reason));
|
|
228
|
+
};
|
|
229
|
+
/** A usable row: brokered, and its grant has not lapsed. */
|
|
230
|
+
const usable = (connection: CfConnection | null): connection is CfConnection =>
|
|
231
|
+
Boolean(connection?.connected && !connection.needsReconnect);
|
|
232
|
+
const onMessage = (event: MessageEvent) => {
|
|
233
|
+
if (event.origin !== location.origin) {
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const data = event.data as { source?: string; status?: string; reason?: string | null };
|
|
237
|
+
if (!data || data.source !== "jx-cf") {
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (data.status === "error") {
|
|
241
|
+
fail(data.reason ?? "Cloudflare authorization failed");
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
/* Any other status — "connected", "pick-account", or one a shell of another vintage sends —
|
|
245
|
+
is a CLAIM that the callback ran, and the broker row is what adjudicates it. */
|
|
246
|
+
void fetchCfConnection().then(
|
|
247
|
+
(connection) => {
|
|
248
|
+
settle(usable(connection) ? { connection, status: "connected" } : { status: "timeout" });
|
|
249
|
+
},
|
|
250
|
+
/* This promise had no rejection handler, so one network blip left the listener installed,
|
|
251
|
+
the timer armed and the popup open forever. */
|
|
252
|
+
() => {
|
|
253
|
+
fail("Cloudflare connected, but the connection could not be confirmed");
|
|
254
|
+
},
|
|
255
|
+
);
|
|
256
|
+
};
|
|
257
|
+
window.addEventListener("message", onMessage);
|
|
258
|
+
const poll = async () => {
|
|
259
|
+
if (Date.now() > deadline) {
|
|
260
|
+
settle({ status: "timeout" });
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
let connection: CfConnection | null = null;
|
|
264
|
+
let answered = true;
|
|
265
|
+
try {
|
|
266
|
+
connection = await fetchCfConnection();
|
|
267
|
+
} catch {
|
|
268
|
+
answered = false; // A blip is not an answer: re-arm and ask again.
|
|
269
|
+
}
|
|
270
|
+
/* The poll is a FALLBACK for shells whose postMessage never arrives, so it may only settle on
|
|
271
|
+
proof that THIS flow stored a fresh token: a usable row that was NOT already usable at the
|
|
272
|
+
baseline. For a reconnect, `needsReconnect` flipping false is that proof; for a connection
|
|
273
|
+
that was healthy all along nothing the poll can see is, so it must never settle — the relay
|
|
274
|
+
or the popup-closed path answers that case instead. */
|
|
275
|
+
if (answered && usable(connection) && !healthyBaseline) {
|
|
276
|
+
settle({ connection, status: "connected" });
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
if (popup.closed) {
|
|
280
|
+
const final = await fetchCfConnection().catch(() => null);
|
|
281
|
+
settle(usable(final) ? { connection: final, status: "connected" } : { status: "canceled" });
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
timer = window.setTimeout(() => {
|
|
285
|
+
void poll();
|
|
286
|
+
}, 1500);
|
|
287
|
+
};
|
|
288
|
+
timer = window.setTimeout(() => {
|
|
289
|
+
void poll();
|
|
290
|
+
}, 1500);
|
|
291
|
+
});
|
|
292
|
+
}
|
|
153
293
|
|
|
154
294
|
/** Parse an "owner/repo@branch" root key; null when malformed. */
|
|
155
295
|
export function parseRootKey(root: string): CloudProject | null {
|
|
@@ -896,6 +1036,33 @@ export function createCloudPlatform(project: CloudProject | null): StudioPlatfor
|
|
|
896
1036
|
};
|
|
897
1037
|
},
|
|
898
1038
|
|
|
1039
|
+
/**
|
|
1040
|
+
* AI-guided site import, streamed from the PLATFORM's import route rather than a session one.
|
|
1041
|
+
*
|
|
1042
|
+
* Deliberately not routed through `api()`: importing is how a cloud project comes into
|
|
1043
|
+
* existence, so the one mode it has to work in is the project-less hub, where `api()` rejects
|
|
1044
|
+
* before a request is ever made and `base` is "".
|
|
1045
|
+
*
|
|
1046
|
+
* The path is same-origin and relative, so `streamImport`'s plain `fetch` carries the session
|
|
1047
|
+
* cookie under the default `same-origin` credentials mode — the same authority every
|
|
1048
|
+
* `/api/v1/*` call here sends `credentials: "include"` for, with no cloud-shaped option added
|
|
1049
|
+
* to the shared client. A bring-your-own-key run rides on the headers that client already sets,
|
|
1050
|
+
* so the backend sees `X-Api-Key` / `X-Api-Base-URL` exactly as the OSS server does.
|
|
1051
|
+
*
|
|
1052
|
+
* `onReady` is forwarded and this backend never calls it, which is a difference worth naming:
|
|
1053
|
+
* adopting the project mid-run would navigate this page to the editor and abort the request
|
|
1054
|
+
* still writing the import, so the stream carries no `ready` line and the caller adopts on
|
|
1055
|
+
* `done`. That is the same path a backend older than `ready` has always taken.
|
|
1056
|
+
*/
|
|
1057
|
+
async importSite(
|
|
1058
|
+
opts: ImportSiteOptions,
|
|
1059
|
+
onProgress: (evt: ImportProgressEvent) => void,
|
|
1060
|
+
signal?: AbortSignal,
|
|
1061
|
+
onReady?: (evt: ImportReadyEvent) => void,
|
|
1062
|
+
) {
|
|
1063
|
+
return await streamImport("/api/v1/import/site", opts, onProgress, signal, onReady);
|
|
1064
|
+
},
|
|
1065
|
+
|
|
899
1066
|
// ─── Identity & Cloudflare publish surface ──────────────────────────────
|
|
900
1067
|
|
|
901
1068
|
async getUser() {
|
|
@@ -1001,71 +1168,75 @@ export function createCloudPlatform(project: CloudProject | null): StudioPlatfor
|
|
|
1001
1168
|
* OAuth errors (denial, invalid_scope misregistration) REJECT with the real reason instead of
|
|
1002
1169
|
* timing out. A 1.5s poll remains as fallback for older shells / blocked message delivery, and
|
|
1003
1170
|
* popup-blocked browsers fall back to a full-page redirect.
|
|
1171
|
+
*
|
|
1172
|
+
* Promise semantics, which are narrower than they look:
|
|
1173
|
+
*
|
|
1174
|
+
* - **Resolves `null`** only under the SSR guard, where there is no window to open.
|
|
1175
|
+
* - **Resolves an outcome** for all four real endings — `connected`, `redirect` (popup blocked,
|
|
1176
|
+
* page navigating away), `canceled` (popup closed with nothing stored), `timeout` (180s).
|
|
1177
|
+
* - **Rejects** only on a relayed OAuth error, or on a relayed success this platform could not
|
|
1178
|
+
* confirm against the broker.
|
|
1179
|
+
*
|
|
1180
|
+
* Concurrent calls JOIN the flow already running rather than starting a second one: both would
|
|
1181
|
+
* open the same "cf-connect" target, and the first one's cleanup would then close the popup the
|
|
1182
|
+
* second is waiting on.
|
|
1004
1183
|
*/
|
|
1005
|
-
|
|
1184
|
+
cfConnect() {
|
|
1006
1185
|
if (typeof window === "undefined" || typeof location === "undefined") {
|
|
1007
|
-
return null;
|
|
1186
|
+
return Promise.resolve(null);
|
|
1008
1187
|
}
|
|
1009
|
-
const
|
|
1010
|
-
if (
|
|
1011
|
-
|
|
1012
|
-
|
|
1188
|
+
const running = cfConnectFlow;
|
|
1189
|
+
if (running) {
|
|
1190
|
+
try {
|
|
1191
|
+
running.popup?.focus();
|
|
1192
|
+
} catch {
|
|
1193
|
+
// A cross-origin popup may refuse focus; joining the flow is what mattered.
|
|
1194
|
+
}
|
|
1195
|
+
return running.promise;
|
|
1013
1196
|
}
|
|
1014
|
-
const
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
if (connection) {
|
|
1054
|
-
settle(connection);
|
|
1055
|
-
return;
|
|
1056
|
-
}
|
|
1057
|
-
if (popup.closed) {
|
|
1058
|
-
settle(await fetchCfConnection());
|
|
1059
|
-
return;
|
|
1060
|
-
}
|
|
1061
|
-
timer = window.setTimeout(() => {
|
|
1062
|
-
void poll();
|
|
1063
|
-
}, 1500);
|
|
1064
|
-
};
|
|
1065
|
-
timer = window.setTimeout(() => {
|
|
1066
|
-
void poll();
|
|
1067
|
-
}, 1500);
|
|
1197
|
+
const handle: CfConnectFlow = { popup: null, promise: Promise.resolve(null) };
|
|
1198
|
+
cfConnectFlow = handle;
|
|
1199
|
+
handle.promise = runCfConnect(handle).finally(() => {
|
|
1200
|
+
if (cfConnectFlow === handle) {
|
|
1201
|
+
cfConnectFlow = null;
|
|
1202
|
+
}
|
|
1203
|
+
});
|
|
1204
|
+
return handle.promise;
|
|
1205
|
+
},
|
|
1206
|
+
|
|
1207
|
+
/**
|
|
1208
|
+
* Every Cloudflare account this connection can reach (the account picker's rows).
|
|
1209
|
+
*
|
|
1210
|
+
* A non-OK body is the broker's own unusable-connection payload — `{error, code}` naming a
|
|
1211
|
+
* lapsed grant or a connection that never existed — so its sentence is the one worth showing.
|
|
1212
|
+
*/
|
|
1213
|
+
async cfAccounts() {
|
|
1214
|
+
const res = await fetch("/api/v1/cf/accounts", { credentials: "include" });
|
|
1215
|
+
return okJson<CfAccountSummary[]>(res, "Failed to list Cloudflare accounts");
|
|
1216
|
+
},
|
|
1217
|
+
|
|
1218
|
+
/** Store the chosen account on the brokered connection. */
|
|
1219
|
+
async cfSelectAccount(account: { id: string; name?: string }) {
|
|
1220
|
+
const res = await fetch("/api/v1/cf/select-account", {
|
|
1221
|
+
body: JSON.stringify({ accountId: account.id, accountName: account.name }),
|
|
1222
|
+
credentials: "include",
|
|
1223
|
+
headers: { "Content-Type": "application/json" },
|
|
1224
|
+
method: "POST",
|
|
1225
|
+
});
|
|
1226
|
+
if (!res.ok) {
|
|
1227
|
+
throw new Error(await errorMessage(res, "Failed to select a Cloudflare account"));
|
|
1228
|
+
}
|
|
1229
|
+
},
|
|
1230
|
+
|
|
1231
|
+
/** Forget the brokered connection (the broker revokes its tokens upstream). */
|
|
1232
|
+
async cfDisconnect() {
|
|
1233
|
+
const res = await fetch("/api/v1/cf/connection", {
|
|
1234
|
+
credentials: "include",
|
|
1235
|
+
method: "DELETE",
|
|
1068
1236
|
});
|
|
1237
|
+
if (!res.ok) {
|
|
1238
|
+
throw new Error(await errorMessage(res, "Failed to disconnect Cloudflare"));
|
|
1239
|
+
}
|
|
1069
1240
|
},
|
|
1070
1241
|
|
|
1071
1242
|
/** Allowlisted Cloudflare API passthrough (platform injects the OAuth token). */
|
|
@@ -26,6 +26,7 @@ import { activeRegistry } from "../commands/active-registry";
|
|
|
26
26
|
import { currentDeploy, noteDeployment } from "./deploy-checklist";
|
|
27
27
|
import { getPlatform } from "../platform";
|
|
28
28
|
import { getCfToken, setCfToken } from "../services/cf-settings";
|
|
29
|
+
import { resetModelCache } from "../services/ai-models";
|
|
29
30
|
import { projectState } from "../store";
|
|
30
31
|
import type { CfConnection } from "../types";
|
|
31
32
|
import { openModal } from "../ui/layers";
|
|
@@ -76,7 +77,13 @@ async function loadConnection(): Promise<void> {
|
|
|
76
77
|
render();
|
|
77
78
|
try {
|
|
78
79
|
_connection = (await getPlatform().cfConnection?.()) ?? null;
|
|
79
|
-
|
|
80
|
+
/*
|
|
81
|
+
* A lapsed brokered connection is `connected: true`, and asking Cloudflare anything through one
|
|
82
|
+
* is a guaranteed 401. Every such call landed in the catch below, which nulled the connection —
|
|
83
|
+
* so the panel showed the FIRST-TIME "Connect Cloudflare" invitation next to a raw
|
|
84
|
+
* "Cloudflare API: …" string, and never the one sentence that was true: it expired.
|
|
85
|
+
*/
|
|
86
|
+
if (_connection?.connected && !_connection.needsReconnect) {
|
|
80
87
|
_accounts = await listAccounts();
|
|
81
88
|
_form.accountId = _connection.accountId ?? _accounts[0]?.id ?? "";
|
|
82
89
|
const deploy = currentDeploy();
|
|
@@ -122,15 +129,37 @@ function openAccounts(): void {
|
|
|
122
129
|
void activeRegistry()?.run("app.preferences", { section: "accounts" });
|
|
123
130
|
}
|
|
124
131
|
|
|
132
|
+
/**
|
|
133
|
+
* Run the hosted OAuth flow and act on how it ended.
|
|
134
|
+
*
|
|
135
|
+
* The four endings are not interchangeable, and collapsing them is what left a user staring at an
|
|
136
|
+
* unchanged panel: a deadline that passed and a popup the browser turned into a full-page redirect
|
|
137
|
+
* both resolved null, and both were reported as "not completed" — one of them while the document
|
|
138
|
+
* was already navigating away.
|
|
139
|
+
*/
|
|
125
140
|
async function hostedConnect(): Promise<void> {
|
|
126
141
|
_busy = true;
|
|
142
|
+
_error = "";
|
|
127
143
|
render();
|
|
128
144
|
try {
|
|
129
|
-
await getPlatform().cfConnect?.();
|
|
145
|
+
const outcome = (await getPlatform().cfConnect?.()) ?? null;
|
|
146
|
+
if (outcome?.status === "timeout") {
|
|
147
|
+
_error =
|
|
148
|
+
"The Cloudflare window didn't finish. Sign in there, then reconnect — nothing was changed.";
|
|
149
|
+
} else if (outcome?.status === "connected" && !outcome.connection.accountId) {
|
|
150
|
+
/* Lazily: this module is the publish modal, and the picker drags the dialog layer with it. */
|
|
151
|
+
const { openCfAccountPicker } = await import("../ui/cf-account-picker");
|
|
152
|
+
await openCfAccountPicker();
|
|
153
|
+
}
|
|
154
|
+
/* `canceled` and `redirect` say nothing. The user closed the popup, or the page is on its way
|
|
155
|
+
to Cloudflare — an apology painted over either one is noise. */
|
|
130
156
|
} catch (error) {
|
|
131
157
|
_error = error instanceof Error ? error.message : String(error);
|
|
132
158
|
}
|
|
133
159
|
_busy = false;
|
|
160
|
+
// The assistant's gate reads the same grant through /models, so a reconnect it does not hear
|
|
161
|
+
// About leaves it insisting the connection is dead.
|
|
162
|
+
resetModelCache();
|
|
134
163
|
await loadConnection();
|
|
135
164
|
}
|
|
136
165
|
|
|
@@ -194,6 +223,32 @@ function fieldRow(label: string, input: unknown) {
|
|
|
194
223
|
`;
|
|
195
224
|
}
|
|
196
225
|
|
|
226
|
+
/**
|
|
227
|
+
* The connection exists and has lapsed.
|
|
228
|
+
*
|
|
229
|
+
* Its own template rather than a line inside {@link credentialTpl}, because the honest words are the
|
|
230
|
+
* opposite of that one's: nothing needs to be set up, nothing was lost, and the only action is to
|
|
231
|
+
* sign in again. The panel used to say "Connect your Cloudflare account to publish this site" to a
|
|
232
|
+
* user who had already done exactly that.
|
|
233
|
+
*/
|
|
234
|
+
function lapsedTpl() {
|
|
235
|
+
return html`
|
|
236
|
+
<p>
|
|
237
|
+
Your Cloudflare connection has expired, so publishing cannot reach your account. Reconnect to
|
|
238
|
+
restore it — this site's Pages project and its settings are untouched.
|
|
239
|
+
</p>
|
|
240
|
+
<sp-button
|
|
241
|
+
variant="accent"
|
|
242
|
+
?disabled=${_busy}
|
|
243
|
+
@click=${() => {
|
|
244
|
+
void hostedConnect();
|
|
245
|
+
}}
|
|
246
|
+
>
|
|
247
|
+
${_busy ? "Reconnecting…" : "Reconnect Cloudflare"}
|
|
248
|
+
</sp-button>
|
|
249
|
+
`;
|
|
250
|
+
}
|
|
251
|
+
|
|
197
252
|
function credentialTpl() {
|
|
198
253
|
const platform = getPlatform();
|
|
199
254
|
if (platform.cfConnect) {
|
|
@@ -393,6 +448,11 @@ function bodyTpl() {
|
|
|
393
448
|
if (_connection === "loading") {
|
|
394
449
|
return html`<p>Checking Cloudflare connection…</p>`;
|
|
395
450
|
}
|
|
451
|
+
// Before the credential template, and that order is the fix: a lapsed row is `connected: true`,
|
|
452
|
+
// So neither branch below could ever have claimed it.
|
|
453
|
+
if (_connection?.connected && _connection.needsReconnect) {
|
|
454
|
+
return lapsedTpl();
|
|
455
|
+
}
|
|
396
456
|
if (!_connection?.connected) {
|
|
397
457
|
return credentialTpl();
|
|
398
458
|
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Ai-import-tools.ts — `import_site`, the assistant's other way to bootstrap a project.
|
|
3
3
|
*
|
|
4
|
-
* A sibling of `create_project` (`ai-project-tools.ts`) with a different backend: it drives
|
|
5
|
-
*
|
|
6
|
-
* the transcript draws, and then adopts the result exactly as a
|
|
4
|
+
* A sibling of `create_project` (`ai-project-tools.ts`) with a different backend: it drives whatever
|
|
5
|
+
* import pipeline the platform's `importSite` reaches — a local headless Chrome, or a hosted one —
|
|
6
|
+
* streams its progress into a record the transcript draws, and then adopts the result exactly as a
|
|
7
|
+
* scaffold is adopted. Its own module
|
|
7
8
|
* rather than an eighth tool in `ai-project-tools.ts`, which is already 650 lines under a per-file
|
|
8
9
|
* coverage bar.
|
|
9
10
|
*
|
|
@@ -115,6 +116,40 @@ function isAbsolutePath(path: string): boolean {
|
|
|
115
116
|
return /^(?:[a-zA-Z]:[/\\]|\/)/.test(path);
|
|
116
117
|
}
|
|
117
118
|
|
|
119
|
+
/**
|
|
120
|
+
* `owner/repo`, conservatively — the destination a `createDestination: "repo"` platform writes to.
|
|
121
|
+
*
|
|
122
|
+
* Narrower than GitHub's own rules on purpose: this guards a string the MODEL may supply, and the
|
|
123
|
+
* cost of refusing an exotic-but-legal name is one clarifying question, while the cost of accepting
|
|
124
|
+
* a path-shaped or query-shaped one is a request the backend has to defend against.
|
|
125
|
+
*/
|
|
126
|
+
const REPO_DESTINATION = /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?\/[A-Za-z0-9._-]+$/;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Whether `destination` is a shape THIS platform can write to, or the sentence saying why not.
|
|
130
|
+
*
|
|
131
|
+
* The platform's `createDestination` decides, not the string's own look: a hosted backend creates a
|
|
132
|
+
* GitHub repository and has no filesystem to hold an absolute path, and a desktop backend has no
|
|
133
|
+
* repository to create. Answering by inspecting the string would accept `owner/repo` on desktop,
|
|
134
|
+
* where it silently means a relative directory nobody named.
|
|
135
|
+
*
|
|
136
|
+
* @param {string} destination - The resolved destination, from the model or the wizard's brief
|
|
137
|
+
* @returns {string | null} The refusal, or null when it is usable
|
|
138
|
+
*/
|
|
139
|
+
function destinationProblem(destination: string): string | null {
|
|
140
|
+
if (getPlatform().createDestination === "repo") {
|
|
141
|
+
if (!REPO_DESTINATION.test(destination)) {
|
|
142
|
+
return `"directory" must name a repository as "owner/repo": ${destination}`;
|
|
143
|
+
}
|
|
144
|
+
} else if (!isAbsolutePath(destination)) {
|
|
145
|
+
return `"directory" must be an absolute path: ${destination}`;
|
|
146
|
+
}
|
|
147
|
+
if (destination.split(/[/\\]/).includes("..")) {
|
|
148
|
+
return `"directory" must not contain "..": ${destination}`;
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
|
|
118
153
|
/**
|
|
119
154
|
* One run has already succeeded in this session.
|
|
120
155
|
*
|
|
@@ -242,8 +277,10 @@ export function registerImportTools(
|
|
|
242
277
|
directory: {
|
|
243
278
|
type: "string",
|
|
244
279
|
description:
|
|
245
|
-
"
|
|
246
|
-
|
|
280
|
+
"Where the project goes, in whichever form this studio's backend uses: an absolute " +
|
|
281
|
+
'folder on a filesystem host, or "owner/repo" on a host where a project IS a ' +
|
|
282
|
+
"repository. Omit it when an import was started from the New Project form — that " +
|
|
283
|
+
"destination is the one the user chose, and it wins.",
|
|
247
284
|
},
|
|
248
285
|
depth: {
|
|
249
286
|
type: "number",
|
|
@@ -361,15 +398,16 @@ export function registerImportTools(
|
|
|
361
398
|
const destination = requested || brief?.directory || "";
|
|
362
399
|
if (!destination) {
|
|
363
400
|
return toolError(
|
|
364
|
-
|
|
365
|
-
"the project
|
|
401
|
+
platform.createDestination === "repo"
|
|
402
|
+
? 'Pass "directory" — the "owner/repo" to create the project as. Ask the user which ' +
|
|
403
|
+
"account and repository name rather than guessing."
|
|
404
|
+
: 'Pass "directory" — the absolute folder to create the project in. Ask the user ' +
|
|
405
|
+
"where the project should live rather than guessing.",
|
|
366
406
|
);
|
|
367
407
|
}
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
if (destination.split(/[/\\]/).includes("..")) {
|
|
372
|
-
return toolError(`"directory" must not contain "..": ${destination}`);
|
|
408
|
+
const problem = destinationProblem(destination);
|
|
409
|
+
if (problem) {
|
|
410
|
+
return toolError(problem);
|
|
373
411
|
}
|
|
374
412
|
|
|
375
413
|
const id = currentToolCallId();
|