@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.
Files changed (86) hide show
  1. package/dist/chunks/{2020-xp8vnwma.js → 2020-n6tfwnda.js} +2 -2
  2. package/dist/chunks/{Overlay.dev-723bv0df.js → Overlay.dev-etqjwyf9.js} +4 -4
  3. package/dist/chunks/cf-account-picker-en86v7x7.js +163 -0
  4. package/dist/chunks/{content-source-t88vb2dr.js → content-source-48xg86wj.js} +17 -17
  5. package/dist/chunks/{convert-to-repeater-dhw1nrz9.js → convert-to-repeater-43tfn7cs.js} +24 -24
  6. package/dist/chunks/{entry-commands-6z6bhxg0.js → entry-commands-e63f1xc0.js} +20 -20
  7. package/dist/chunks/{extension-sections-3ydgjbdy.js → extension-sections-txv08xxg.js} +23 -23
  8. package/dist/chunks/{file-ops-rskmhxyq.js → file-ops-wbh4hhsx.js} +16 -16
  9. package/dist/chunks/{files-0cratsr0.js → files-3kfkwhk4.js} +25 -24
  10. package/dist/chunks/{focus-trap.esm-ggawf1hy.js → focus-trap.esm-9t2drp0g.js} +6 -5
  11. package/dist/chunks/{format-host-tgcmfs4p.js → format-host-w16q81tf.js} +2 -2
  12. package/dist/chunks/fs-events-hrrg328g.js +1 -1
  13. package/dist/chunks/{git-panel-0yywyk76.js → git-panel-532whr1y.js} +16 -15
  14. package/dist/chunks/{index-nj44jpzj.js → index-wcnt3r9g.js} +2 -2
  15. package/dist/chunks/{jx-validate-8q2g05x1.js → jx-validate-8er6gbxy.js} +3 -3
  16. package/dist/chunks/{layers-panel-z3tqe5cd.js → layers-panel-07hm7g5j.js} +19 -19
  17. package/dist/chunks/{monaco-setup-rws7k4br.js → monaco-setup-vx9bhp1d.js} +2 -2
  18. package/dist/chunks/{publish-panel-54jvhskq.js → publish-panel-v9st96ja.js} +11 -10
  19. package/dist/chunks/{quick-search-cgfmjwq5.js → quick-search-zccjp6gv.js} +26 -25
  20. package/dist/chunks/{right-panel-cqp8bc4v.js → right-panel-vm3b6kwy.js} +17 -17
  21. package/dist/chunks/{sp-overlay.dev-yztmtrs1.js → sp-overlay.dev-qgvhnxf4.js} +5 -5
  22. package/dist/chunks/{sp-tooltip.dev-xcvpnqr0.js → sp-tooltip.dev-4sastdnw.js} +3 -3
  23. package/dist/chunks/{studio-txwkm6p9.js → studio-0bq6eh7v.js} +2 -2
  24. package/dist/chunks/studio-1ngbejft.js +181 -0
  25. package/dist/chunks/{studio-gv8vvshc.js → studio-1wagb550.js} +3 -3
  26. package/dist/chunks/{studio-kjjsjak4.js → studio-2b946z26.js} +58 -57
  27. package/dist/chunks/{studio-4pqqxbrt.js → studio-3bqpr0zh.js} +16 -16
  28. package/dist/chunks/{studio-v38hmvx1.js → studio-4b5z1x43.js} +42 -8
  29. package/dist/chunks/{studio-9c4k4vhv.js → studio-4n2b5gzb.js} +4 -4
  30. package/dist/chunks/{studio-p8j86wag.js → studio-4r1pv6zf.js} +2 -2
  31. package/dist/chunks/{studio-mmv72a27.js → studio-5fx3g9jg.js} +29 -48
  32. package/dist/chunks/{studio-5rywrkxz.js → studio-5myknm0a.js} +42 -42
  33. package/dist/chunks/{studio-sf7zjgv1.js → studio-6yw4y3my.js} +3 -3
  34. package/dist/chunks/{studio-pdfjntbk.js → studio-950k0dx6.js} +35 -35
  35. package/dist/chunks/{studio-eenfkt4e.js → studio-ahn3pdgx.js} +5 -5
  36. package/dist/chunks/{studio-eesa1myv.js → studio-b55y6ewx.js} +9 -9
  37. package/dist/chunks/{studio-cb12yz9w.js → studio-cmb03bg5.js} +99 -1264
  38. package/dist/chunks/studio-dm19q43r.js +195 -0
  39. package/dist/chunks/{studio-ahjfdy6e.js → studio-ef49pnzx.js} +6 -6
  40. package/dist/chunks/{studio-wx5609xd.js → studio-ew0djmhg.js} +10 -10
  41. package/dist/chunks/{studio-9r6j5ev1.js → studio-f3wqjtk5.js} +4 -3
  42. package/dist/chunks/{studio-8swrjbny.js → studio-fbf92ckf.js} +14 -14
  43. package/dist/chunks/{studio-4q0wznzf.js → studio-gcye7qka.js} +442 -126
  44. package/dist/chunks/{studio-ex8zyp98.js → studio-gfgb8pe6.js} +23 -184
  45. package/dist/chunks/{studio-r1r7afxx.js → studio-jc994dnr.js} +24 -24
  46. package/dist/chunks/{studio-yxrkyv8a.js → studio-jkftxyvz.js} +52 -3416
  47. package/dist/chunks/{studio-hbtp0eh1.js → studio-msd847zn.js} +13 -13
  48. package/dist/chunks/{studio-kyk2v92a.js → studio-mtfnaqs9.js} +14 -14
  49. package/dist/chunks/{studio-vadzgkrz.js → studio-n6j9t10q.js} +2 -2
  50. package/dist/chunks/{studio-68n67094.js → studio-q9jd1a60.js} +4 -4
  51. package/dist/chunks/studio-qcy05dp8.js +1743 -0
  52. package/dist/chunks/{studio-htsf0w6m.js → studio-qjy2z9bv.js} +12 -30
  53. package/dist/chunks/{studio-2zkz8evn.js → studio-trxrabqf.js} +12 -12
  54. package/dist/chunks/{studio-5q69gfag.js → studio-x4qkdat5.js} +12 -11
  55. package/dist/chunks/studio-x813zp48.js +195 -0
  56. package/dist/chunks/{studio-1hbqnt19.js → studio-zqazsct2.js} +29 -29
  57. package/dist/chunks/{workspace-xpres40v.js → workspace-fjx52ryv.js} +4 -4
  58. package/dist/iframe-entry.js +34 -33
  59. package/dist/manifest.json +7 -0
  60. package/dist/studio.js +534 -546
  61. package/favicon.ico +0 -0
  62. package/index.html +1 -0
  63. package/package.json +15 -14
  64. package/src/about/about-modal.ts +4 -3
  65. package/src/files/files.ts +6 -4
  66. package/src/files/media-paths.ts +13 -42
  67. package/src/files/media-usage.ts +41 -131
  68. package/src/hosting/document.ts +2 -0
  69. package/src/hosting/layout.ts +8 -0
  70. package/src/new-project/import-tab.ts +37 -15
  71. package/src/new-project/location-fields.ts +8 -5
  72. package/src/panels/ai-chat/composer.ts +10 -0
  73. package/src/platforms/cloud.ts +241 -70
  74. package/src/publish/publish-panel.ts +62 -2
  75. package/src/services/ai-import-tools.ts +50 -12
  76. package/src/services/ai-models.ts +108 -1
  77. package/src/services/context-manager.ts +12 -1
  78. package/src/services/tool-executor.ts +11 -0
  79. package/src/settings/preferences-accounts.ts +193 -12
  80. package/src/settings/preferences-dialog.ts +75 -18
  81. package/src/types.ts +27 -3
  82. package/src/ui/ai-managed-connect.ts +62 -13
  83. package/src/ui/ai-model-picker.ts +29 -2
  84. package/src/ui/cf-account-picker.ts +200 -0
  85. package/styles/shell.css +36 -0
  86. package/dist/chunks/studio-bw5dz6km.js +0 -608
@@ -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/importSite,
10
- * package install/outdated/set-versions (package ops are manifest-only edits), multi-window,
11
- * gitClone, resolveClass, component discovery, code services.
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
- /** Current hosted Cloudflare connection; null when none is brokered yet. */
128
- async function fetchCfConnection(): Promise<{
129
- connected: boolean;
130
- accountId?: string | undefined;
131
- accountName?: string | undefined;
132
- } | null> {
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
- type CfConnectionInfo = Awaited<ReturnType<typeof fetchCfConnection>>;
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
- async cfConnect() {
1184
+ cfConnect() {
1006
1185
  if (typeof window === "undefined" || typeof location === "undefined") {
1007
- return null;
1186
+ return Promise.resolve(null);
1008
1187
  }
1009
- const popup = window.open("/api/v1/cf/connect", "cf-connect", "width=980,height=780");
1010
- if (!popup) {
1011
- location.assign("/api/v1/cf/connect");
1012
- return null;
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 deadline = Date.now() + 180_000;
1015
- return new Promise<CfConnectionInfo>((resolve, reject) => {
1016
- let timer = 0;
1017
- const cleanup = () => {
1018
- window.removeEventListener("message", onMessage);
1019
- window.clearTimeout(timer);
1020
- if (!popup.closed) {
1021
- popup.close();
1022
- }
1023
- };
1024
- const settle = (connection: CfConnectionInfo) => {
1025
- cleanup();
1026
- resolve(connection);
1027
- };
1028
- const fail = (reason: string) => {
1029
- cleanup();
1030
- reject(new Error(reason));
1031
- };
1032
- const onMessage = (event: MessageEvent) => {
1033
- if (event.origin !== location.origin) {
1034
- return;
1035
- }
1036
- const data = event.data as { source?: string; status?: string; reason?: string | null };
1037
- if (!data || data.source !== "jx-cf") {
1038
- return;
1039
- }
1040
- if (data.status === "error") {
1041
- fail(data.reason ?? "Cloudflare authorization failed");
1042
- return;
1043
- }
1044
- void fetchCfConnection().then(settle);
1045
- };
1046
- window.addEventListener("message", onMessage);
1047
- const poll = async () => {
1048
- if (Date.now() > deadline) {
1049
- settle(null);
1050
- return;
1051
- }
1052
- const connection = await fetchCfConnection();
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
- if (_connection?.connected) {
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 the
5
- * headless-Chrome pipeline behind `POST /__studio/import-site`, streams its progress into a record
6
- * the transcript draws, and then adopts the result exactly as a scaffold is adopted. Its own module
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
- "Absolute destination folder. Omit it when an import was started from the New " +
246
- "Project form — that destination is the one the user chose, and it wins.",
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
- 'Pass "directory" — the absolute folder to create the project in. Ask the user where ' +
365
- "the project should live rather than guessing.",
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
- if (!isAbsolutePath(destination)) {
369
- return toolError(`"directory" must be an absolute path: ${destination}`);
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();