@liiift-studio/deploy-vercel-from-sanity 0.2.2 → 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/README.md CHANGED
@@ -16,7 +16,9 @@
16
16
  - **Live status** with automatic polling — Queued → Building → Ready / Error
17
17
  - **Build timer** showing elapsed time while a deploy is in progress
18
18
  - **Cancel** in-progress deployments
19
+ - **Deploy-complete notifications** — Studio toast when a build finishes, errors, or is canceled
19
20
  - **Copy deployment URL** with one click
21
+ - **GitHub commit links** — commit SHA links directly to the GitHub commit when repo metadata is available
20
22
  - **Inline error log viewer** — see build errors without leaving the studio
21
23
  - **Deployment history** per target
22
24
  - **"Open in Vercel"** link to your project dashboard
@@ -122,6 +124,41 @@ tools: (prev, { currentUser }) => {
122
124
  3. While a deployment is active (Queued / Initializing / Building), it polls every 5 seconds.
123
125
  4. Clicking **Deploy** POSTs to the hook URL — Vercel queues a new build.
124
126
  5. If a deploy fails, clicking **Show error details** fetches the last 30 build log lines from the Vercel API inline.
127
+ 6. A Studio toast notification fires when a deployment completes (Ready, Error, or Canceled).
128
+
129
+ > **Polling and rate limits** — Active deployments are polled every 5 seconds per target. With many simultaneous active deploys, API call volume adds up. Vercel's rate limit is generous for normal use, but studios with a large number of targets triggering concurrently may hit `429` errors. The plugin surfaces these with a clear message.
130
+
131
+ ---
132
+
133
+ ## Troubleshooting
134
+
135
+ ### "Token is invalid or expired"
136
+
137
+ Your Vercel API token has been revoked or expired. Go to **Vercel → Settings → Tokens**, create a new token with **Full Account** scope, and reconnect it in the Deploy tab (top-right → *Token connected* button).
138
+
139
+ ### "Token lacks the required permissions"
140
+
141
+ The token exists but was created with insufficient scope. Vercel tokens need **Full Account** scope to read deployments. Delete the token and create a new one with the correct scope.
142
+
143
+ ### "Resource not found — check the deploy hook URL and team ID"
144
+
145
+ Either the deploy hook URL is incorrect, or the project belongs to a Vercel team and the **Team ID** field is missing from the deploy target. Find your Team ID at **Vercel → Settings → General → Team ID** (starts with `team_`) and add it to the deploy target via the edit menu.
146
+
147
+ ### "Rate limit reached"
148
+
149
+ The plugin is making too many API calls at once (common when many targets are all actively building). Wait a few seconds — polling will resume automatically.
150
+
151
+ ### Deploy triggers but status never updates
152
+
153
+ This usually means the token is missing. The plugin can trigger deploys via hook URL without a token, but it needs an API token to read back deployment status. Connect a token using the button in the top-right of the Deploy tab.
154
+
155
+ ### Commit SHA does not link to GitHub
156
+
157
+ The SHA link requires Vercel to return GitHub repo metadata with the deployment. This is present on deployments triggered by GitHub pushes but not on manually triggered hook deploys. Manually triggered deploys will show the SHA as plain text with a tooltip.
158
+
159
+ ### No error logs shown after a failed build
160
+
161
+ If "No stderr or stdout was captured" appears, the build may have failed before producing log output, or the events API returned no lines. Use **Open in Vercel** to view the full build log in the Vercel dashboard.
125
162
 
126
163
  ---
127
164
 
package/dist/index.d.mts CHANGED
@@ -33,6 +33,10 @@ interface VercelDeployment {
33
33
  githubCommitRef?: string;
34
34
  githubCommitSha?: string;
35
35
  githubCommitAuthorName?: string;
36
+ /** GitHub repo in "org/repo" format — used to construct commit links */
37
+ githubRepo?: string;
38
+ /** GitHub org slug — fallback when githubRepo is absent */
39
+ githubCommitOrg?: string;
36
40
  };
37
41
  }
38
42
  /** Plugin configuration options */
package/dist/index.d.ts CHANGED
@@ -33,6 +33,10 @@ interface VercelDeployment {
33
33
  githubCommitRef?: string;
34
34
  githubCommitSha?: string;
35
35
  githubCommitAuthorName?: string;
36
+ /** GitHub repo in "org/repo" format — used to construct commit links */
37
+ githubRepo?: string;
38
+ /** GitHub org slug — fallback when githubRepo is absent */
39
+ githubCommitOrg?: string;
36
40
  };
37
41
  }
38
42
  /** Plugin configuration options */
package/dist/index.js CHANGED
@@ -51,8 +51,8 @@ async function vercelFetch(path, token, init) {
51
51
  }
52
52
  });
53
53
  if (!res.ok) {
54
- const text = await res.text().catch(() => res.statusText);
55
- throw new Error(`Vercel API ${res.status}: ${text}`);
54
+ const hint = res.status === 401 ? " \u2014 token is invalid or expired. Reconnect your API token." : res.status === 403 ? " \u2014 token lacks the required permissions. Ensure it has Full Account scope." : res.status === 404 ? " \u2014 resource not found. Check the deploy hook URL and team ID." : res.status === 429 ? " \u2014 rate limit reached. Wait a moment and try again." : res.status >= 500 ? " \u2014 Vercel is experiencing issues. Try again shortly." : "";
55
+ throw new Error(`Vercel API ${res.status}${hint}`);
56
56
  }
57
57
  return res.json();
58
58
  }
@@ -160,6 +160,12 @@ function stateLabel(state) {
160
160
  return { label: "Unknown", tone: "default" };
161
161
  }
162
162
  }
163
+ function githubCommitHref(meta) {
164
+ if (!meta?.githubCommitSha) return null;
165
+ const repo = meta.githubRepo ?? null;
166
+ if (!repo) return null;
167
+ return `https://github.com/${repo}/commit/${meta.githubCommitSha}`;
168
+ }
163
169
  function projectHref(inspectorUrl) {
164
170
  if (!inspectorUrl) return null;
165
171
  try {
@@ -278,6 +284,7 @@ var POLL_INTERVAL_MS = 5e3;
278
284
  var LABEL_WIDTH = 64;
279
285
  function DeployItem({ target, token, onDelete, onEdit }) {
280
286
  const { projectId, hookId } = parseHookUrl(target.url);
287
+ const toast = (0, import_ui3.useToast)();
281
288
  const [deployments, setDeployments] = (0, import_react2.useState)([]);
282
289
  const [loadingInitial, setLoadingInitial] = (0, import_react2.useState)(true);
283
290
  const [triggering, setTriggering] = (0, import_react2.useState)(false);
@@ -313,6 +320,21 @@ function DeployItem({ target, token, onDelete, onEdit }) {
313
320
  (0, import_react2.useEffect)(() => {
314
321
  if (triggering && latest && latest.state !== void 0) setTriggering(false);
315
322
  }, [triggering, latest]);
323
+ const prevStateRef = (0, import_react2.useRef)(void 0);
324
+ (0, import_react2.useEffect)(() => {
325
+ const current = latest?.state;
326
+ const prev = prevStateRef.current;
327
+ if (prev && isActiveState(prev) && current && !isActiveState(current)) {
328
+ if (current === "READY") {
329
+ toast.push({ status: "success", title: `${target.name} deployed`, description: "Build completed successfully" });
330
+ } else if (current === "ERROR") {
331
+ toast.push({ status: "error", title: `${target.name} failed`, description: "Build encountered an error \u2014 check error details" });
332
+ } else if (current === "CANCELED") {
333
+ toast.push({ status: "warning", title: `${target.name} canceled`, description: "Deployment was canceled" });
334
+ }
335
+ }
336
+ prevStateRef.current = current;
337
+ }, [latest?.state, target.name, toast]);
316
338
  (0, import_react2.useEffect)(() => {
317
339
  setShowErrorLogs(false);
318
340
  setErrorLines([]);
@@ -359,11 +381,12 @@ function DeployItem({ target, token, onDelete, onEdit }) {
359
381
  }, [latest?.uid, token, target.teamId, fetchDeployments]);
360
382
  const copyUrl = (0, import_react2.useCallback)(() => {
361
383
  if (!latest?.url) return;
362
- navigator.clipboard.writeText(`https://${latest.url}`).then(() => {
384
+ const fullUrl = `https://${latest.url}`;
385
+ navigator.clipboard.writeText(fullUrl).then(() => {
363
386
  setCopied(true);
364
387
  setTimeout(() => setCopied(false), 2e3);
365
- }).catch((err) => {
366
- console.error("deploy-vercel-from-sanity: clipboard error", err);
388
+ }).catch(() => {
389
+ window.prompt("Copy deployment URL:", fullUrl);
367
390
  });
368
391
  }, [latest?.url]);
369
392
  const fetchErrorLogs = (0, import_react2.useCallback)(async () => {
@@ -377,7 +400,7 @@ function DeployItem({ target, token, onDelete, onEdit }) {
377
400
  teamId: target.teamId
378
401
  });
379
402
  const lines = events.filter((e) => e.type === "stderr" || e.type === "stdout").map((e) => e.text ?? "").filter(Boolean).reverse().slice(-30);
380
- setErrorLines(lines.length > 0 ? lines : ["No log output captured."]);
403
+ setErrorLines(lines.length > 0 ? lines : ["No stderr or stdout was captured for this build. Open the full build log in Vercel for details."]);
381
404
  } catch (err) {
382
405
  setLogError(err instanceof Error ? err.message : "Failed to load build logs");
383
406
  } finally {
@@ -392,6 +415,7 @@ function DeployItem({ target, token, onDelete, onEdit }) {
392
415
  const commitMsg = latest?.meta?.githubCommitMessage?.split("\n")[0];
393
416
  const sha = shortSha(latest?.meta?.githubCommitSha);
394
417
  const fullSha = latest?.meta?.githubCommitSha;
418
+ const commitHref = githubCommitHref(latest?.meta);
395
419
  const creator = latest?.creator?.username;
396
420
  const deployedAt = latest?.created ? timeAgo(latest.created) : null;
397
421
  const vercelProjectUrl = projectHref(latest?.inspectorUrl);
@@ -509,15 +533,16 @@ function DeployItem({ target, token, onDelete, onEdit }) {
509
533
  {
510
534
  content: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_ui3.Box, { padding: 2, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_ui3.Text, { size: 1, children: commitMsg ?? sha }) }),
511
535
  portal: true,
512
- children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
513
- import_ui3.Text,
536
+ children: commitHref ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
537
+ "a",
514
538
  {
515
- size: 1,
516
- muted: true,
517
- style: { cursor: "default", fontFamily: "monospace" },
518
- children: sha
539
+ href: commitHref,
540
+ target: "_blank",
541
+ rel: "noreferrer",
542
+ style: { color: "inherit", textDecoration: "none" },
543
+ children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_ui3.Text, { size: 1, muted: true, style: { cursor: "pointer", fontFamily: "monospace" }, children: sha })
519
544
  }
520
- )
545
+ ) : /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_ui3.Text, { size: 1, muted: true, style: { cursor: "default", fontFamily: "monospace" }, children: sha })
521
546
  }
522
547
  ),
523
548
  creator && /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_ui3.Text, { size: 1, muted: true, children: [
@@ -580,7 +605,7 @@ function DeployItem({ target, token, onDelete, onEdit }) {
580
605
  ] }) })
581
606
  ] }),
582
607
  !loadingLogs && !logError && errorLines.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_ui3.Stack, { space: 2, children: [
583
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_ui3.Box, { style: { maxHeight: 240, overflowY: "auto", fontFamily: "monospace", fontSize: 11, lineHeight: 1.6 }, children: errorLines.map((line, i) => /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_ui3.Code, { size: 1, style: { display: "block", whiteSpace: "pre-wrap", wordBreak: "break-all" }, children: line }, i)) }),
608
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_ui3.Box, { style: { maxHeight: 240, overflowY: "auto", fontFamily: "monospace", fontSize: 13, lineHeight: 1.6 }, children: errorLines.map((line, i) => /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_ui3.Code, { size: 1, style: { display: "block", whiteSpace: "pre-wrap", wordBreak: "break-all" }, children: line }, i)) }),
584
609
  safeHref(latest?.inspectorUrl) && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("a", { href: safeHref(latest?.inspectorUrl), target: "_blank", rel: "noreferrer", style: { color: "inherit" }, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_ui3.Flex, { align: "center", gap: 1, children: [
585
610
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_icons2.LaunchIcon, {}),
586
611
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_ui3.Text, { size: 1, children: "View full logs in Vercel" })
@@ -605,7 +630,7 @@ function DeployItem({ target, token, onDelete, onEdit }) {
605
630
  style: { width: "100%", justifyContent: "flex-start", borderRadius: 0, cursor: "pointer" }
606
631
  }
607
632
  ),
608
- showDetails && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_ui3.Card, { tone: "primary", padding: 3, style: { borderRadius: 0 }, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_ui3.Stack, { space: 2, children: [
633
+ showDetails && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_ui3.Card, { tone: "primary", padding: 3, className: "dvfs-accordion-content", style: { borderRadius: 0, borderTop: "1px solid rgba(128,128,128,0.15)" }, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_ui3.Stack, { space: 2, children: [
609
634
  /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_ui3.Flex, { gap: 2, align: "center", children: [
610
635
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_ui3.Text, { size: 0, muted: true, weight: "semibold", style: { minWidth: LABEL_WIDTH }, children: "Project" }),
611
636
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_ui3.Text, { size: 0, muted: true, style: { fontFamily: "monospace" }, children: projectId || "\u2014" })
@@ -878,7 +903,11 @@ function DeployTargetForm({ initial, onSaved, onClose }) {
878
903
  placeholder: "team_xxxxxxxx"
879
904
  }
880
905
  ),
881
- /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(import_ui5.Text, { size: 0, muted: true, children: "Required for team-owned Vercel projects." })
906
+ /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)(import_ui5.Text, { size: 0, muted: true, children: [
907
+ "Required for team-owned Vercel projects. Find it at Vercel \u2192 Settings \u2192 General \u2192 Team ID (starts with ",
908
+ /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("code", { children: "team_" }),
909
+ ")."
910
+ ] })
882
911
  ] }),
883
912
  /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)(import_ui5.Flex, { align: "center", gap: 3, children: [
884
913
  /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(
@@ -901,7 +930,7 @@ function DeployTargetForm({ initial, onSaved, onClose }) {
901
930
  }
902
931
 
903
932
  // src/version.ts
904
- var VERSION = "0.2.0";
933
+ var VERSION = "1.0.0";
905
934
 
906
935
  // src/components/DeployTool.tsx
907
936
  var import_jsx_runtime6 = require("react/jsx-runtime");
@@ -941,7 +970,7 @@ function DeployTool() {
941
970
  const style = document.createElement("style");
942
971
  style.id = "dvfs-styles";
943
972
  style.textContent = `
944
- @media (max-width: 600px) {
973
+ @media (max-width: 768px) {
945
974
  .dvfs-header { flex-wrap: wrap !important; row-gap: 8px !important; }
946
975
  .dvfs-header-actions { width: 100% !important; flex-wrap: wrap !important; justify-content: flex-start !important; }
947
976
  .dvfs-grid { grid-template-columns: 1fr !important; }
@@ -949,6 +978,13 @@ function DeployTool() {
949
978
  .dvfs-deploy-col { width: 100% !important; align-self: auto !important; }
950
979
  .dvfs-deploy-col button { border-radius: 3px !important; }
951
980
  }
981
+ @keyframes dvfs-open {
982
+ from { opacity: 0; transform: translateY(-4px); }
983
+ to { opacity: 1; transform: translateY(0); }
984
+ }
985
+ .dvfs-accordion-content {
986
+ animation: dvfs-open 0.15s ease-out;
987
+ }
952
988
  `;
953
989
  document.head.appendChild(style);
954
990
  return () => {
@@ -1047,7 +1083,7 @@ function DeployTool() {
1047
1083
  ] }) }),
1048
1084
  targets.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("div", { className: "dvfs-grid", style: {
1049
1085
  display: "grid",
1050
- gridTemplateColumns: "repeat(auto-fill, minmax(540px, 1fr))",
1086
+ gridTemplateColumns: "repeat(auto-fill, minmax(min(540px, 100%), 1fr))",
1051
1087
  gap: "16px",
1052
1088
  alignItems: "start"
1053
1089
  }, children: targets.map((target) => /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(
package/dist/index.mjs CHANGED
@@ -15,7 +15,7 @@ import {
15
15
  Spinner as Spinner4,
16
16
  Button as Button5,
17
17
  Dialog as Dialog4,
18
- useToast
18
+ useToast as useToast2
19
19
  } from "@sanity/ui";
20
20
  import { TokenIcon, TrashIcon as TrashIcon2, WarningOutlineIcon as WarningOutlineIcon2, AddIcon } from "@sanity/icons";
21
21
 
@@ -34,7 +34,8 @@ import {
34
34
  MenuButton,
35
35
  Menu,
36
36
  MenuItem,
37
- Code
37
+ Code,
38
+ useToast
38
39
  } from "@sanity/ui";
39
40
  import {
40
41
  ClockIcon,
@@ -62,8 +63,8 @@ async function vercelFetch(path, token, init) {
62
63
  }
63
64
  });
64
65
  if (!res.ok) {
65
- const text = await res.text().catch(() => res.statusText);
66
- throw new Error(`Vercel API ${res.status}: ${text}`);
66
+ const hint = res.status === 401 ? " \u2014 token is invalid or expired. Reconnect your API token." : res.status === 403 ? " \u2014 token lacks the required permissions. Ensure it has Full Account scope." : res.status === 404 ? " \u2014 resource not found. Check the deploy hook URL and team ID." : res.status === 429 ? " \u2014 rate limit reached. Wait a moment and try again." : res.status >= 500 ? " \u2014 Vercel is experiencing issues. Try again shortly." : "";
67
+ throw new Error(`Vercel API ${res.status}${hint}`);
67
68
  }
68
69
  return res.json();
69
70
  }
@@ -171,6 +172,12 @@ function stateLabel(state) {
171
172
  return { label: "Unknown", tone: "default" };
172
173
  }
173
174
  }
175
+ function githubCommitHref(meta) {
176
+ if (!meta?.githubCommitSha) return null;
177
+ const repo = meta.githubRepo ?? null;
178
+ if (!repo) return null;
179
+ return `https://github.com/${repo}/commit/${meta.githubCommitSha}`;
180
+ }
174
181
  function projectHref(inspectorUrl) {
175
182
  if (!inspectorUrl) return null;
176
183
  try {
@@ -299,6 +306,7 @@ var POLL_INTERVAL_MS = 5e3;
299
306
  var LABEL_WIDTH = 64;
300
307
  function DeployItem({ target, token, onDelete, onEdit }) {
301
308
  const { projectId, hookId } = parseHookUrl(target.url);
309
+ const toast = useToast();
302
310
  const [deployments, setDeployments] = useState2([]);
303
311
  const [loadingInitial, setLoadingInitial] = useState2(true);
304
312
  const [triggering, setTriggering] = useState2(false);
@@ -334,6 +342,21 @@ function DeployItem({ target, token, onDelete, onEdit }) {
334
342
  useEffect2(() => {
335
343
  if (triggering && latest && latest.state !== void 0) setTriggering(false);
336
344
  }, [triggering, latest]);
345
+ const prevStateRef = useRef(void 0);
346
+ useEffect2(() => {
347
+ const current = latest?.state;
348
+ const prev = prevStateRef.current;
349
+ if (prev && isActiveState(prev) && current && !isActiveState(current)) {
350
+ if (current === "READY") {
351
+ toast.push({ status: "success", title: `${target.name} deployed`, description: "Build completed successfully" });
352
+ } else if (current === "ERROR") {
353
+ toast.push({ status: "error", title: `${target.name} failed`, description: "Build encountered an error \u2014 check error details" });
354
+ } else if (current === "CANCELED") {
355
+ toast.push({ status: "warning", title: `${target.name} canceled`, description: "Deployment was canceled" });
356
+ }
357
+ }
358
+ prevStateRef.current = current;
359
+ }, [latest?.state, target.name, toast]);
337
360
  useEffect2(() => {
338
361
  setShowErrorLogs(false);
339
362
  setErrorLines([]);
@@ -380,11 +403,12 @@ function DeployItem({ target, token, onDelete, onEdit }) {
380
403
  }, [latest?.uid, token, target.teamId, fetchDeployments]);
381
404
  const copyUrl = useCallback2(() => {
382
405
  if (!latest?.url) return;
383
- navigator.clipboard.writeText(`https://${latest.url}`).then(() => {
406
+ const fullUrl = `https://${latest.url}`;
407
+ navigator.clipboard.writeText(fullUrl).then(() => {
384
408
  setCopied(true);
385
409
  setTimeout(() => setCopied(false), 2e3);
386
- }).catch((err) => {
387
- console.error("deploy-vercel-from-sanity: clipboard error", err);
410
+ }).catch(() => {
411
+ window.prompt("Copy deployment URL:", fullUrl);
388
412
  });
389
413
  }, [latest?.url]);
390
414
  const fetchErrorLogs = useCallback2(async () => {
@@ -398,7 +422,7 @@ function DeployItem({ target, token, onDelete, onEdit }) {
398
422
  teamId: target.teamId
399
423
  });
400
424
  const lines = events.filter((e) => e.type === "stderr" || e.type === "stdout").map((e) => e.text ?? "").filter(Boolean).reverse().slice(-30);
401
- setErrorLines(lines.length > 0 ? lines : ["No log output captured."]);
425
+ setErrorLines(lines.length > 0 ? lines : ["No stderr or stdout was captured for this build. Open the full build log in Vercel for details."]);
402
426
  } catch (err) {
403
427
  setLogError(err instanceof Error ? err.message : "Failed to load build logs");
404
428
  } finally {
@@ -413,6 +437,7 @@ function DeployItem({ target, token, onDelete, onEdit }) {
413
437
  const commitMsg = latest?.meta?.githubCommitMessage?.split("\n")[0];
414
438
  const sha = shortSha(latest?.meta?.githubCommitSha);
415
439
  const fullSha = latest?.meta?.githubCommitSha;
440
+ const commitHref = githubCommitHref(latest?.meta);
416
441
  const creator = latest?.creator?.username;
417
442
  const deployedAt = latest?.created ? timeAgo(latest.created) : null;
418
443
  const vercelProjectUrl = projectHref(latest?.inspectorUrl);
@@ -530,15 +555,16 @@ function DeployItem({ target, token, onDelete, onEdit }) {
530
555
  {
531
556
  content: /* @__PURE__ */ jsx3(Box2, { padding: 2, children: /* @__PURE__ */ jsx3(Text2, { size: 1, children: commitMsg ?? sha }) }),
532
557
  portal: true,
533
- children: /* @__PURE__ */ jsx3(
534
- Text2,
558
+ children: commitHref ? /* @__PURE__ */ jsx3(
559
+ "a",
535
560
  {
536
- size: 1,
537
- muted: true,
538
- style: { cursor: "default", fontFamily: "monospace" },
539
- children: sha
561
+ href: commitHref,
562
+ target: "_blank",
563
+ rel: "noreferrer",
564
+ style: { color: "inherit", textDecoration: "none" },
565
+ children: /* @__PURE__ */ jsx3(Text2, { size: 1, muted: true, style: { cursor: "pointer", fontFamily: "monospace" }, children: sha })
540
566
  }
541
- )
567
+ ) : /* @__PURE__ */ jsx3(Text2, { size: 1, muted: true, style: { cursor: "default", fontFamily: "monospace" }, children: sha })
542
568
  }
543
569
  ),
544
570
  creator && /* @__PURE__ */ jsxs3(Text2, { size: 1, muted: true, children: [
@@ -601,7 +627,7 @@ function DeployItem({ target, token, onDelete, onEdit }) {
601
627
  ] }) })
602
628
  ] }),
603
629
  !loadingLogs && !logError && errorLines.length > 0 && /* @__PURE__ */ jsxs3(Stack2, { space: 2, children: [
604
- /* @__PURE__ */ jsx3(Box2, { style: { maxHeight: 240, overflowY: "auto", fontFamily: "monospace", fontSize: 11, lineHeight: 1.6 }, children: errorLines.map((line, i) => /* @__PURE__ */ jsx3(Code, { size: 1, style: { display: "block", whiteSpace: "pre-wrap", wordBreak: "break-all" }, children: line }, i)) }),
630
+ /* @__PURE__ */ jsx3(Box2, { style: { maxHeight: 240, overflowY: "auto", fontFamily: "monospace", fontSize: 13, lineHeight: 1.6 }, children: errorLines.map((line, i) => /* @__PURE__ */ jsx3(Code, { size: 1, style: { display: "block", whiteSpace: "pre-wrap", wordBreak: "break-all" }, children: line }, i)) }),
605
631
  safeHref(latest?.inspectorUrl) && /* @__PURE__ */ jsx3("a", { href: safeHref(latest?.inspectorUrl), target: "_blank", rel: "noreferrer", style: { color: "inherit" }, children: /* @__PURE__ */ jsxs3(Flex3, { align: "center", gap: 1, children: [
606
632
  /* @__PURE__ */ jsx3(LaunchIcon2, {}),
607
633
  /* @__PURE__ */ jsx3(Text2, { size: 1, children: "View full logs in Vercel" })
@@ -626,7 +652,7 @@ function DeployItem({ target, token, onDelete, onEdit }) {
626
652
  style: { width: "100%", justifyContent: "flex-start", borderRadius: 0, cursor: "pointer" }
627
653
  }
628
654
  ),
629
- showDetails && /* @__PURE__ */ jsx3(Card2, { tone: "primary", padding: 3, style: { borderRadius: 0 }, children: /* @__PURE__ */ jsxs3(Stack2, { space: 2, children: [
655
+ showDetails && /* @__PURE__ */ jsx3(Card2, { tone: "primary", padding: 3, className: "dvfs-accordion-content", style: { borderRadius: 0, borderTop: "1px solid rgba(128,128,128,0.15)" }, children: /* @__PURE__ */ jsxs3(Stack2, { space: 2, children: [
630
656
  /* @__PURE__ */ jsxs3(Flex3, { gap: 2, align: "center", children: [
631
657
  /* @__PURE__ */ jsx3(Text2, { size: 0, muted: true, weight: "semibold", style: { minWidth: LABEL_WIDTH }, children: "Project" }),
632
658
  /* @__PURE__ */ jsx3(Text2, { size: 0, muted: true, style: { fontFamily: "monospace" }, children: projectId || "\u2014" })
@@ -910,7 +936,11 @@ function DeployTargetForm({ initial, onSaved, onClose }) {
910
936
  placeholder: "team_xxxxxxxx"
911
937
  }
912
938
  ),
913
- /* @__PURE__ */ jsx5(Text4, { size: 0, muted: true, children: "Required for team-owned Vercel projects." })
939
+ /* @__PURE__ */ jsxs5(Text4, { size: 0, muted: true, children: [
940
+ "Required for team-owned Vercel projects. Find it at Vercel \u2192 Settings \u2192 General \u2192 Team ID (starts with ",
941
+ /* @__PURE__ */ jsx5("code", { children: "team_" }),
942
+ ")."
943
+ ] })
914
944
  ] }),
915
945
  /* @__PURE__ */ jsxs5(Flex5, { align: "center", gap: 3, children: [
916
946
  /* @__PURE__ */ jsx5(
@@ -933,7 +963,7 @@ function DeployTargetForm({ initial, onSaved, onClose }) {
933
963
  }
934
964
 
935
965
  // src/version.ts
936
- var VERSION = "0.2.0";
966
+ var VERSION = "1.0.0";
937
967
 
938
968
  // src/components/DeployTool.tsx
939
969
  import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
@@ -941,7 +971,7 @@ var TOKEN_QUERY = `*[_id == "config.vercelDeploy"][0].accessToken`;
941
971
  var TARGETS_QUERY = `*[_type == "vercel_deploy"] | order(_createdAt asc)`;
942
972
  function DeployTool() {
943
973
  const client = useClient3({ apiVersion: "2025-01-01" });
944
- const toast = useToast();
974
+ const toast = useToast2();
945
975
  const [token, setToken] = useState5(null);
946
976
  const [targets, setTargets] = useState5([]);
947
977
  const [loading, setLoading] = useState5(true);
@@ -973,7 +1003,7 @@ function DeployTool() {
973
1003
  const style = document.createElement("style");
974
1004
  style.id = "dvfs-styles";
975
1005
  style.textContent = `
976
- @media (max-width: 600px) {
1006
+ @media (max-width: 768px) {
977
1007
  .dvfs-header { flex-wrap: wrap !important; row-gap: 8px !important; }
978
1008
  .dvfs-header-actions { width: 100% !important; flex-wrap: wrap !important; justify-content: flex-start !important; }
979
1009
  .dvfs-grid { grid-template-columns: 1fr !important; }
@@ -981,6 +1011,13 @@ function DeployTool() {
981
1011
  .dvfs-deploy-col { width: 100% !important; align-self: auto !important; }
982
1012
  .dvfs-deploy-col button { border-radius: 3px !important; }
983
1013
  }
1014
+ @keyframes dvfs-open {
1015
+ from { opacity: 0; transform: translateY(-4px); }
1016
+ to { opacity: 1; transform: translateY(0); }
1017
+ }
1018
+ .dvfs-accordion-content {
1019
+ animation: dvfs-open 0.15s ease-out;
1020
+ }
984
1021
  `;
985
1022
  document.head.appendChild(style);
986
1023
  return () => {
@@ -1079,7 +1116,7 @@ function DeployTool() {
1079
1116
  ] }) }),
1080
1117
  targets.length > 0 && /* @__PURE__ */ jsx6("div", { className: "dvfs-grid", style: {
1081
1118
  display: "grid",
1082
- gridTemplateColumns: "repeat(auto-fill, minmax(540px, 1fr))",
1119
+ gridTemplateColumns: "repeat(auto-fill, minmax(min(540px, 100%), 1fr))",
1083
1120
  gap: "16px",
1084
1121
  alignItems: "start"
1085
1122
  }, children: targets.map((target) => /* @__PURE__ */ jsx6(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liiift-studio/deploy-vercel-from-sanity",
3
- "version": "0.2.2",
3
+ "version": "1.0.0",
4
4
  "description": "Sanity Studio plugin — trigger and monitor Vercel deployments with full status, history, and build logs. Supports v3, v4, and v5.",
5
5
  "license": "MIT",
6
6
  "author": "Liiift Studio",
@@ -2,14 +2,14 @@
2
2
  import { useState, useEffect, useCallback, useRef } from 'react'
3
3
  import {
4
4
  Card, Box, Stack, Flex, Text, Button, Tooltip, Badge, Spinner,
5
- MenuButton, Menu, MenuItem, Code,
5
+ MenuButton, Menu, MenuItem, Code, useToast,
6
6
  } from '@sanity/ui'
7
7
  import {
8
8
  ClockIcon, TrashIcon, EllipsisVerticalIcon, LaunchIcon,
9
9
  CopyIcon, CheckmarkIcon, WarningOutlineIcon, ChevronDownIcon, ChevronUpIcon, EditIcon, SchemaIcon
10
10
  } from '@sanity/icons'
11
11
  import { listDeployments, cancelDeployment, triggerDeploy, getDeploymentEvents } from '../lib/api'
12
- import { parseHookUrl, isActiveState, formatDuration, timeAgo, shortSha, safeHref, projectHref } from '../lib/helpers'
12
+ import { parseHookUrl, isActiveState, formatDuration, timeAgo, shortSha, safeHref, projectHref, githubCommitHref } from '../lib/helpers'
13
13
  import { StatusBadge } from './StatusBadge'
14
14
  import { DeployHistory } from './DeployHistory'
15
15
  import type { DeployTarget, VercelDeployment } from '../types'
@@ -26,6 +26,7 @@ interface DeployItemProps {
26
26
 
27
27
  export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps) {
28
28
  const { projectId, hookId } = parseHookUrl(target.url)
29
+ const toast = useToast()
29
30
 
30
31
  const [deployments, setDeployments] = useState<VercelDeployment[]>([])
31
32
  const [loadingInitial, setLoadingInitial] = useState(true)
@@ -69,6 +70,23 @@ export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps)
69
70
  if (triggering && latest && latest.state !== undefined) setTriggering(false)
70
71
  }, [triggering, latest])
71
72
 
73
+ // ── Deploy-complete toast ─────────────────────────────────────────────────
74
+ const prevStateRef = useRef<string | undefined>(undefined)
75
+ useEffect(() => {
76
+ const current = latest?.state
77
+ const prev = prevStateRef.current
78
+ if (prev && isActiveState(prev as never) && current && !isActiveState(current as never)) {
79
+ if (current === 'READY') {
80
+ toast.push({ status: 'success', title: `${target.name} deployed`, description: 'Build completed successfully' })
81
+ } else if (current === 'ERROR') {
82
+ toast.push({ status: 'error', title: `${target.name} failed`, description: 'Build encountered an error — check error details' })
83
+ } else if (current === 'CANCELED') {
84
+ toast.push({ status: 'warning', title: `${target.name} canceled`, description: 'Deployment was canceled' })
85
+ }
86
+ }
87
+ prevStateRef.current = current
88
+ }, [latest?.state, target.name, toast])
89
+
72
90
  useEffect(() => {
73
91
  setShowErrorLogs(false)
74
92
  setErrorLines([])
@@ -119,11 +137,13 @@ export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps)
119
137
 
120
138
  const copyUrl = useCallback(() => {
121
139
  if (!latest?.url) return
122
- navigator.clipboard.writeText(`https://${latest.url}`).then(() => {
140
+ const fullUrl = `https://${latest.url}`
141
+ navigator.clipboard.writeText(fullUrl).then(() => {
123
142
  setCopied(true)
124
143
  setTimeout(() => setCopied(false), 2000)
125
- }).catch(err => {
126
- console.error('deploy-vercel-from-sanity: clipboard error', err)
144
+ }).catch(() => {
145
+ // Clipboard API unavailable — surface the URL for manual copy
146
+ window.prompt('Copy deployment URL:', fullUrl)
127
147
  })
128
148
  }, [latest?.url])
129
149
 
@@ -143,7 +163,7 @@ export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps)
143
163
  .filter(Boolean)
144
164
  .reverse()
145
165
  .slice(-30)
146
- setErrorLines(lines.length > 0 ? lines : ['No log output captured.'])
166
+ setErrorLines(lines.length > 0 ? lines : ['No stderr or stdout was captured for this build. Open the full build log in Vercel for details.'])
147
167
  } catch (err) {
148
168
  setLogError(err instanceof Error ? err.message : 'Failed to load build logs')
149
169
  } finally {
@@ -161,6 +181,7 @@ export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps)
161
181
  const commitMsg = latest?.meta?.githubCommitMessage?.split('\n')[0]
162
182
  const sha = shortSha(latest?.meta?.githubCommitSha)
163
183
  const fullSha = latest?.meta?.githubCommitSha
184
+ const commitHref = githubCommitHref(latest?.meta)
164
185
  const creator = latest?.creator?.username
165
186
  const deployedAt = latest?.created ? timeAgo(latest.created) : null
166
187
  const vercelProjectUrl = projectHref(latest?.inspectorUrl)
@@ -304,7 +325,7 @@ export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps)
304
325
  </>
305
326
  )}
306
327
 
307
- {/* Commit SHA — tooltip shows full message */}
328
+ {/* Commit SHA — links to GitHub if repo info available, tooltip shows full message */}
308
329
  {sha && (
309
330
  <Tooltip
310
331
  content={
@@ -314,13 +335,22 @@ export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps)
314
335
  }
315
336
  portal
316
337
  >
317
- <Text
318
- size={1}
319
- muted
320
- style={{ cursor: 'default', fontFamily: 'monospace' }}
321
- >
322
- {sha}
323
- </Text>
338
+ {commitHref ? (
339
+ <a
340
+ href={commitHref}
341
+ target="_blank"
342
+ rel="noreferrer"
343
+ style={{ color: 'inherit', textDecoration: 'none' }}
344
+ >
345
+ <Text size={1} muted style={{ cursor: 'pointer', fontFamily: 'monospace' }}>
346
+ {sha}
347
+ </Text>
348
+ </a>
349
+ ) : (
350
+ <Text size={1} muted style={{ cursor: 'default', fontFamily: 'monospace' }}>
351
+ {sha}
352
+ </Text>
353
+ )}
324
354
  </Tooltip>
325
355
  )}
326
356
 
@@ -399,7 +429,7 @@ export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps)
399
429
  )}
400
430
  {!loadingLogs && !logError && errorLines.length > 0 && (
401
431
  <Stack space={2}>
402
- <Box style={{ maxHeight: 240, overflowY: 'auto', fontFamily: 'monospace', fontSize: 11, lineHeight: 1.6 }}>
432
+ <Box style={{ maxHeight: 240, overflowY: 'auto', fontFamily: 'monospace', fontSize: 13, lineHeight: 1.6 }}>
403
433
  {errorLines.map((line, i) => (
404
434
  <Code key={i} size={1} style={{ display: 'block', whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
405
435
  {line}
@@ -445,7 +475,7 @@ export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps)
445
475
  style={{ width: '100%', justifyContent: 'flex-start', borderRadius: 0, cursor: 'pointer' }}
446
476
  />
447
477
  {showDetails && (
448
- <Card tone="primary" padding={3} style={{ borderRadius: 0 }}>
478
+ <Card tone="primary" padding={3} className="dvfs-accordion-content" style={{ borderRadius: 0, borderTop: '1px solid rgba(128,128,128,0.15)' }}>
449
479
  <Stack space={2}>
450
480
  <Flex gap={2} align="center">
451
481
  <Text size={0} muted weight="semibold" style={{ minWidth: LABEL_WIDTH }}>Project</Text>
@@ -114,7 +114,9 @@ export function DeployTargetForm({ initial, onSaved, onClose }: DeployTargetForm
114
114
  onChange={e => setTeamId((e.target as HTMLInputElement).value)}
115
115
  placeholder="team_xxxxxxxx"
116
116
  />
117
- <Text size={0} muted>Required for team-owned Vercel projects.</Text>
117
+ <Text size={0} muted>
118
+ Required for team-owned Vercel projects. Find it at Vercel → Settings → General → Team ID (starts with <code>team_</code>).
119
+ </Text>
118
120
  </Stack>
119
121
 
120
122
  {/* Disable delete */}
@@ -52,7 +52,7 @@ export function DeployTool() {
52
52
  const style = document.createElement('style')
53
53
  style.id = 'dvfs-styles'
54
54
  style.textContent = `
55
- @media (max-width: 600px) {
55
+ @media (max-width: 768px) {
56
56
  .dvfs-header { flex-wrap: wrap !important; row-gap: 8px !important; }
57
57
  .dvfs-header-actions { width: 100% !important; flex-wrap: wrap !important; justify-content: flex-start !important; }
58
58
  .dvfs-grid { grid-template-columns: 1fr !important; }
@@ -60,6 +60,13 @@ export function DeployTool() {
60
60
  .dvfs-deploy-col { width: 100% !important; align-self: auto !important; }
61
61
  .dvfs-deploy-col button { border-radius: 3px !important; }
62
62
  }
63
+ @keyframes dvfs-open {
64
+ from { opacity: 0; transform: translateY(-4px); }
65
+ to { opacity: 1; transform: translateY(0); }
66
+ }
67
+ .dvfs-accordion-content {
68
+ animation: dvfs-open 0.15s ease-out;
69
+ }
63
70
  `
64
71
  document.head.appendChild(style)
65
72
  return () => { document.getElementById('dvfs-styles')?.remove() }
@@ -181,7 +188,7 @@ export function DeployTool() {
181
188
  {targets.length > 0 && (
182
189
  <div className="dvfs-grid" style={{
183
190
  display: 'grid',
184
- gridTemplateColumns: 'repeat(auto-fill, minmax(540px, 1fr))',
191
+ gridTemplateColumns: 'repeat(auto-fill, minmax(min(540px, 100%), 1fr))',
185
192
  gap: '16px',
186
193
  alignItems: 'start',
187
194
  }}>
package/src/lib/api.ts CHANGED
@@ -16,8 +16,14 @@ async function vercelFetch<T>(path: string, token: string, init?: RequestInit):
16
16
  },
17
17
  })
18
18
  if (!res.ok) {
19
- const text = await res.text().catch(() => res.statusText)
20
- throw new Error(`Vercel API ${res.status}: ${text}`)
19
+ const hint =
20
+ res.status === 401 ? ' — token is invalid or expired. Reconnect your API token.' :
21
+ res.status === 403 ? ' — token lacks the required permissions. Ensure it has Full Account scope.' :
22
+ res.status === 404 ? ' — resource not found. Check the deploy hook URL and team ID.' :
23
+ res.status === 429 ? ' — rate limit reached. Wait a moment and try again.' :
24
+ res.status >= 500 ? ' — Vercel is experiencing issues. Try again shortly.' :
25
+ ''
26
+ throw new Error(`Vercel API ${res.status}${hint}`)
21
27
  }
22
28
  return res.json() as Promise<T>
23
29
  }
@@ -1,5 +1,5 @@
1
1
  // URL parsing and time formatting utilities
2
- import type { VercelDeployState } from '../types'
2
+ import type { VercelDeployment, VercelDeployState } from '../types'
3
3
 
4
4
  /**
5
5
  * Extracts projectId and hookId from a Vercel deploy hook URL.
@@ -85,6 +85,17 @@ export function stateLabel(state: VercelDeployState | undefined): {
85
85
  }
86
86
  }
87
87
 
88
+ /**
89
+ * Constructs a GitHub commit URL from deployment meta fields.
90
+ * Returns null if the required repo or SHA info is not present.
91
+ */
92
+ export function githubCommitHref(meta: VercelDeployment['meta']): string | null {
93
+ if (!meta?.githubCommitSha) return null
94
+ const repo = meta.githubRepo ?? null
95
+ if (!repo) return null
96
+ return `https://github.com/${repo}/commit/${meta.githubCommitSha}`
97
+ }
98
+
88
99
  /**
89
100
  * Extracts the Vercel project dashboard URL from a deployment's inspectorUrl.
90
101
  * inspectorUrl format: https://vercel.com/{team}/{project}/{deploymentId}
package/src/types.ts CHANGED
@@ -42,6 +42,10 @@ export interface VercelDeployment {
42
42
  githubCommitRef?: string
43
43
  githubCommitSha?: string
44
44
  githubCommitAuthorName?: string
45
+ /** GitHub repo in "org/repo" format — used to construct commit links */
46
+ githubRepo?: string
47
+ /** GitHub org slug — fallback when githubRepo is absent */
48
+ githubCommitOrg?: string
45
49
  }
46
50
  }
47
51
 
package/src/version.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  // Package version — keep in sync with package.json
2
- export const VERSION = '0.2.0'
2
+ export const VERSION = '1.0.0'