@liiift-studio/deploy-vercel-from-sanity 0.2.2 → 1.0.1

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
@@ -35,6 +35,7 @@ var import_icons5 = require("@sanity/icons");
35
35
 
36
36
  // src/components/DeployItem.tsx
37
37
  var import_react2 = require("react");
38
+ var import_react_dom = require("react-dom");
38
39
  var import_ui3 = require("@sanity/ui");
39
40
  var import_icons2 = require("@sanity/icons");
40
41
 
@@ -51,8 +52,8 @@ async function vercelFetch(path, token, init) {
51
52
  }
52
53
  });
53
54
  if (!res.ok) {
54
- const text = await res.text().catch(() => res.statusText);
55
- throw new Error(`Vercel API ${res.status}: ${text}`);
55
+ 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." : "";
56
+ throw new Error(`Vercel API ${res.status}${hint}`);
56
57
  }
57
58
  return res.json();
58
59
  }
@@ -160,6 +161,12 @@ function stateLabel(state) {
160
161
  return { label: "Unknown", tone: "default" };
161
162
  }
162
163
  }
164
+ function githubCommitHref(meta) {
165
+ if (!meta?.githubCommitSha) return null;
166
+ const repo = meta.githubRepo ?? null;
167
+ if (!repo) return null;
168
+ return `https://github.com/${repo}/commit/${meta.githubCommitSha}`;
169
+ }
163
170
  function projectHref(inspectorUrl) {
164
171
  if (!inspectorUrl) return null;
165
172
  try {
@@ -278,6 +285,7 @@ var POLL_INTERVAL_MS = 5e3;
278
285
  var LABEL_WIDTH = 64;
279
286
  function DeployItem({ target, token, onDelete, onEdit }) {
280
287
  const { projectId, hookId } = parseHookUrl(target.url);
288
+ const toast = (0, import_ui3.useToast)();
281
289
  const [deployments, setDeployments] = (0, import_react2.useState)([]);
282
290
  const [loadingInitial, setLoadingInitial] = (0, import_react2.useState)(true);
283
291
  const [triggering, setTriggering] = (0, import_react2.useState)(false);
@@ -313,6 +321,21 @@ function DeployItem({ target, token, onDelete, onEdit }) {
313
321
  (0, import_react2.useEffect)(() => {
314
322
  if (triggering && latest && latest.state !== void 0) setTriggering(false);
315
323
  }, [triggering, latest]);
324
+ const prevStateRef = (0, import_react2.useRef)(void 0);
325
+ (0, import_react2.useEffect)(() => {
326
+ const current = latest?.state;
327
+ const prev = prevStateRef.current;
328
+ if (prev && isActiveState(prev) && current && !isActiveState(current)) {
329
+ if (current === "READY") {
330
+ toast.push({ status: "success", title: `${target.name} deployed`, description: "Build completed successfully" });
331
+ } else if (current === "ERROR") {
332
+ toast.push({ status: "error", title: `${target.name} failed`, description: "Build encountered an error \u2014 check error details" });
333
+ } else if (current === "CANCELED") {
334
+ toast.push({ status: "warning", title: `${target.name} canceled`, description: "Deployment was canceled" });
335
+ }
336
+ }
337
+ prevStateRef.current = current;
338
+ }, [latest?.state, target.name, toast]);
316
339
  (0, import_react2.useEffect)(() => {
317
340
  setShowErrorLogs(false);
318
341
  setErrorLines([]);
@@ -336,7 +359,7 @@ function DeployItem({ target, token, onDelete, onEdit }) {
336
359
  }, [isActive, latest?.created]);
337
360
  const deploy = (0, import_react2.useCallback)(async () => {
338
361
  setDeployError(null);
339
- setTriggering(true);
362
+ (0, import_react_dom.flushSync)(() => setTriggering(true));
340
363
  try {
341
364
  await triggerDeploy(target.url);
342
365
  setTimeout(fetchDeployments, 2e3);
@@ -359,11 +382,12 @@ function DeployItem({ target, token, onDelete, onEdit }) {
359
382
  }, [latest?.uid, token, target.teamId, fetchDeployments]);
360
383
  const copyUrl = (0, import_react2.useCallback)(() => {
361
384
  if (!latest?.url) return;
362
- navigator.clipboard.writeText(`https://${latest.url}`).then(() => {
385
+ const fullUrl = `https://${latest.url}`;
386
+ navigator.clipboard.writeText(fullUrl).then(() => {
363
387
  setCopied(true);
364
388
  setTimeout(() => setCopied(false), 2e3);
365
- }).catch((err) => {
366
- console.error("deploy-vercel-from-sanity: clipboard error", err);
389
+ }).catch(() => {
390
+ window.prompt("Copy deployment URL:", fullUrl);
367
391
  });
368
392
  }, [latest?.url]);
369
393
  const fetchErrorLogs = (0, import_react2.useCallback)(async () => {
@@ -377,7 +401,7 @@ function DeployItem({ target, token, onDelete, onEdit }) {
377
401
  teamId: target.teamId
378
402
  });
379
403
  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."]);
404
+ setErrorLines(lines.length > 0 ? lines : ["No stderr or stdout was captured for this build. Open the full build log in Vercel for details."]);
381
405
  } catch (err) {
382
406
  setLogError(err instanceof Error ? err.message : "Failed to load build logs");
383
407
  } finally {
@@ -392,6 +416,7 @@ function DeployItem({ target, token, onDelete, onEdit }) {
392
416
  const commitMsg = latest?.meta?.githubCommitMessage?.split("\n")[0];
393
417
  const sha = shortSha(latest?.meta?.githubCommitSha);
394
418
  const fullSha = latest?.meta?.githubCommitSha;
419
+ const commitHref = githubCommitHref(latest?.meta);
395
420
  const creator = latest?.creator?.username;
396
421
  const deployedAt = latest?.created ? timeAgo(latest.created) : null;
397
422
  const vercelProjectUrl = projectHref(latest?.inspectorUrl);
@@ -408,10 +433,7 @@ function DeployItem({ target, token, onDelete, onEdit }) {
408
433
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("svg", { width: "12", height: "12", viewBox: "0 0 16 16", fill: "currentColor", "aria-hidden": "true", style: { marginLeft: "-0.1em", opacity: 0.5 }, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("path", { d: "M5 3.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0zm0 2.122a2.25 2.25 0 1 0-1.5 0v.878A2.25 2.25 0 0 0 5.75 8.5h1.5v2.128a2.251 2.251 0 1 0 1.5 0V8.5h1.5a2.25 2.25 0 0 0 2.25-2.25v-.878a2.25 2.25 0 1 0-1.5 0v.878a.75.75 0 0 1-.75.75h-4.5A.75.75 0 0 1 5 6.25v-.878zm3.75 7.378a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0zm3-8.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0z" }) })
409
434
  ] }) }),
410
435
  token && !loadingInitial && /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_jsx_runtime3.Fragment, { children: [
411
- triggering ? /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_ui3.Flex, { align: "center", gap: 1, children: [
412
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_ui3.Spinner, { muted: true }),
413
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_ui3.Badge, { tone: "caution", padding: 2, children: "Triggering\u2026" })
414
- ] }) : /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(StatusBadge, { state: latest?.state, showSpinner: true }),
436
+ triggering ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_ui3.Badge, { tone: "caution", padding: 2, children: "Triggering\u2026" }) : /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(StatusBadge, { state: latest?.state, showSpinner: true }),
415
437
  isActiveState(latest?.state) && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
416
438
  import_ui3.Button,
417
439
  {
@@ -509,15 +531,16 @@ function DeployItem({ target, token, onDelete, onEdit }) {
509
531
  {
510
532
  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
533
  portal: true,
512
- children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
513
- import_ui3.Text,
534
+ children: commitHref ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
535
+ "a",
514
536
  {
515
- size: 1,
516
- muted: true,
517
- style: { cursor: "default", fontFamily: "monospace" },
518
- children: sha
537
+ href: commitHref,
538
+ target: "_blank",
539
+ rel: "noreferrer",
540
+ style: { color: "inherit", textDecoration: "none" },
541
+ children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_ui3.Text, { size: 1, muted: true, style: { cursor: "pointer", fontFamily: "monospace" }, children: sha })
519
542
  }
520
- )
543
+ ) : /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_ui3.Text, { size: 1, muted: true, style: { cursor: "default", fontFamily: "monospace" }, children: sha })
521
544
  }
522
545
  ),
523
546
  creator && /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_ui3.Text, { size: 1, muted: true, children: [
@@ -580,7 +603,7 @@ function DeployItem({ target, token, onDelete, onEdit }) {
580
603
  ] }) })
581
604
  ] }),
582
605
  !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)) }),
606
+ /* @__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
607
  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
608
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_icons2.LaunchIcon, {}),
586
609
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_ui3.Text, { size: 1, children: "View full logs in Vercel" })
@@ -605,7 +628,7 @@ function DeployItem({ target, token, onDelete, onEdit }) {
605
628
  style: { width: "100%", justifyContent: "flex-start", borderRadius: 0, cursor: "pointer" }
606
629
  }
607
630
  ),
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: [
631
+ 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
632
  /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_ui3.Flex, { gap: 2, align: "center", children: [
610
633
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_ui3.Text, { size: 0, muted: true, weight: "semibold", style: { minWidth: LABEL_WIDTH }, children: "Project" }),
611
634
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_ui3.Text, { size: 0, muted: true, style: { fontFamily: "monospace" }, children: projectId || "\u2014" })
@@ -878,7 +901,11 @@ function DeployTargetForm({ initial, onSaved, onClose }) {
878
901
  placeholder: "team_xxxxxxxx"
879
902
  }
880
903
  ),
881
- /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(import_ui5.Text, { size: 0, muted: true, children: "Required for team-owned Vercel projects." })
904
+ /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)(import_ui5.Text, { size: 0, muted: true, children: [
905
+ "Required for team-owned Vercel projects. Find it at Vercel \u2192 Settings \u2192 General \u2192 Team ID (starts with ",
906
+ /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("code", { children: "team_" }),
907
+ ")."
908
+ ] })
882
909
  ] }),
883
910
  /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)(import_ui5.Flex, { align: "center", gap: 3, children: [
884
911
  /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(
@@ -901,7 +928,7 @@ function DeployTargetForm({ initial, onSaved, onClose }) {
901
928
  }
902
929
 
903
930
  // src/version.ts
904
- var VERSION = "0.2.0";
931
+ var VERSION = "1.0.0";
905
932
 
906
933
  // src/components/DeployTool.tsx
907
934
  var import_jsx_runtime6 = require("react/jsx-runtime");
@@ -941,7 +968,7 @@ function DeployTool() {
941
968
  const style = document.createElement("style");
942
969
  style.id = "dvfs-styles";
943
970
  style.textContent = `
944
- @media (max-width: 600px) {
971
+ @media (max-width: 768px) {
945
972
  .dvfs-header { flex-wrap: wrap !important; row-gap: 8px !important; }
946
973
  .dvfs-header-actions { width: 100% !important; flex-wrap: wrap !important; justify-content: flex-start !important; }
947
974
  .dvfs-grid { grid-template-columns: 1fr !important; }
@@ -949,6 +976,13 @@ function DeployTool() {
949
976
  .dvfs-deploy-col { width: 100% !important; align-self: auto !important; }
950
977
  .dvfs-deploy-col button { border-radius: 3px !important; }
951
978
  }
979
+ @keyframes dvfs-open {
980
+ from { opacity: 0; transform: translateY(-4px); }
981
+ to { opacity: 1; transform: translateY(0); }
982
+ }
983
+ .dvfs-accordion-content {
984
+ animation: dvfs-open 0.15s ease-out;
985
+ }
952
986
  `;
953
987
  document.head.appendChild(style);
954
988
  return () => {
@@ -1047,7 +1081,7 @@ function DeployTool() {
1047
1081
  ] }) }),
1048
1082
  targets.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("div", { className: "dvfs-grid", style: {
1049
1083
  display: "grid",
1050
- gridTemplateColumns: "repeat(auto-fill, minmax(540px, 1fr))",
1084
+ gridTemplateColumns: "repeat(auto-fill, minmax(min(540px, 100%), 1fr))",
1051
1085
  gap: "16px",
1052
1086
  alignItems: "start"
1053
1087
  }, children: targets.map((target) => /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(
package/dist/index.mjs CHANGED
@@ -15,12 +15,13 @@ 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
 
22
22
  // src/components/DeployItem.tsx
23
23
  import { useState as useState2, useEffect as useEffect2, useCallback as useCallback2, useRef } from "react";
24
+ import { flushSync } from "react-dom";
24
25
  import {
25
26
  Card as Card2,
26
27
  Box as Box2,
@@ -34,7 +35,8 @@ import {
34
35
  MenuButton,
35
36
  Menu,
36
37
  MenuItem,
37
- Code
38
+ Code,
39
+ useToast
38
40
  } from "@sanity/ui";
39
41
  import {
40
42
  ClockIcon,
@@ -62,8 +64,8 @@ async function vercelFetch(path, token, init) {
62
64
  }
63
65
  });
64
66
  if (!res.ok) {
65
- const text = await res.text().catch(() => res.statusText);
66
- throw new Error(`Vercel API ${res.status}: ${text}`);
67
+ 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." : "";
68
+ throw new Error(`Vercel API ${res.status}${hint}`);
67
69
  }
68
70
  return res.json();
69
71
  }
@@ -171,6 +173,12 @@ function stateLabel(state) {
171
173
  return { label: "Unknown", tone: "default" };
172
174
  }
173
175
  }
176
+ function githubCommitHref(meta) {
177
+ if (!meta?.githubCommitSha) return null;
178
+ const repo = meta.githubRepo ?? null;
179
+ if (!repo) return null;
180
+ return `https://github.com/${repo}/commit/${meta.githubCommitSha}`;
181
+ }
174
182
  function projectHref(inspectorUrl) {
175
183
  if (!inspectorUrl) return null;
176
184
  try {
@@ -299,6 +307,7 @@ var POLL_INTERVAL_MS = 5e3;
299
307
  var LABEL_WIDTH = 64;
300
308
  function DeployItem({ target, token, onDelete, onEdit }) {
301
309
  const { projectId, hookId } = parseHookUrl(target.url);
310
+ const toast = useToast();
302
311
  const [deployments, setDeployments] = useState2([]);
303
312
  const [loadingInitial, setLoadingInitial] = useState2(true);
304
313
  const [triggering, setTriggering] = useState2(false);
@@ -334,6 +343,21 @@ function DeployItem({ target, token, onDelete, onEdit }) {
334
343
  useEffect2(() => {
335
344
  if (triggering && latest && latest.state !== void 0) setTriggering(false);
336
345
  }, [triggering, latest]);
346
+ const prevStateRef = useRef(void 0);
347
+ useEffect2(() => {
348
+ const current = latest?.state;
349
+ const prev = prevStateRef.current;
350
+ if (prev && isActiveState(prev) && current && !isActiveState(current)) {
351
+ if (current === "READY") {
352
+ toast.push({ status: "success", title: `${target.name} deployed`, description: "Build completed successfully" });
353
+ } else if (current === "ERROR") {
354
+ toast.push({ status: "error", title: `${target.name} failed`, description: "Build encountered an error \u2014 check error details" });
355
+ } else if (current === "CANCELED") {
356
+ toast.push({ status: "warning", title: `${target.name} canceled`, description: "Deployment was canceled" });
357
+ }
358
+ }
359
+ prevStateRef.current = current;
360
+ }, [latest?.state, target.name, toast]);
337
361
  useEffect2(() => {
338
362
  setShowErrorLogs(false);
339
363
  setErrorLines([]);
@@ -357,7 +381,7 @@ function DeployItem({ target, token, onDelete, onEdit }) {
357
381
  }, [isActive, latest?.created]);
358
382
  const deploy = useCallback2(async () => {
359
383
  setDeployError(null);
360
- setTriggering(true);
384
+ flushSync(() => setTriggering(true));
361
385
  try {
362
386
  await triggerDeploy(target.url);
363
387
  setTimeout(fetchDeployments, 2e3);
@@ -380,11 +404,12 @@ function DeployItem({ target, token, onDelete, onEdit }) {
380
404
  }, [latest?.uid, token, target.teamId, fetchDeployments]);
381
405
  const copyUrl = useCallback2(() => {
382
406
  if (!latest?.url) return;
383
- navigator.clipboard.writeText(`https://${latest.url}`).then(() => {
407
+ const fullUrl = `https://${latest.url}`;
408
+ navigator.clipboard.writeText(fullUrl).then(() => {
384
409
  setCopied(true);
385
410
  setTimeout(() => setCopied(false), 2e3);
386
- }).catch((err) => {
387
- console.error("deploy-vercel-from-sanity: clipboard error", err);
411
+ }).catch(() => {
412
+ window.prompt("Copy deployment URL:", fullUrl);
388
413
  });
389
414
  }, [latest?.url]);
390
415
  const fetchErrorLogs = useCallback2(async () => {
@@ -398,7 +423,7 @@ function DeployItem({ target, token, onDelete, onEdit }) {
398
423
  teamId: target.teamId
399
424
  });
400
425
  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."]);
426
+ setErrorLines(lines.length > 0 ? lines : ["No stderr or stdout was captured for this build. Open the full build log in Vercel for details."]);
402
427
  } catch (err) {
403
428
  setLogError(err instanceof Error ? err.message : "Failed to load build logs");
404
429
  } finally {
@@ -413,6 +438,7 @@ function DeployItem({ target, token, onDelete, onEdit }) {
413
438
  const commitMsg = latest?.meta?.githubCommitMessage?.split("\n")[0];
414
439
  const sha = shortSha(latest?.meta?.githubCommitSha);
415
440
  const fullSha = latest?.meta?.githubCommitSha;
441
+ const commitHref = githubCommitHref(latest?.meta);
416
442
  const creator = latest?.creator?.username;
417
443
  const deployedAt = latest?.created ? timeAgo(latest.created) : null;
418
444
  const vercelProjectUrl = projectHref(latest?.inspectorUrl);
@@ -429,10 +455,7 @@ function DeployItem({ target, token, onDelete, onEdit }) {
429
455
  /* @__PURE__ */ jsx3("svg", { width: "12", height: "12", viewBox: "0 0 16 16", fill: "currentColor", "aria-hidden": "true", style: { marginLeft: "-0.1em", opacity: 0.5 }, children: /* @__PURE__ */ jsx3("path", { d: "M5 3.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0zm0 2.122a2.25 2.25 0 1 0-1.5 0v.878A2.25 2.25 0 0 0 5.75 8.5h1.5v2.128a2.251 2.251 0 1 0 1.5 0V8.5h1.5a2.25 2.25 0 0 0 2.25-2.25v-.878a2.25 2.25 0 1 0-1.5 0v.878a.75.75 0 0 1-.75.75h-4.5A.75.75 0 0 1 5 6.25v-.878zm3.75 7.378a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0zm3-8.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0z" }) })
430
456
  ] }) }),
431
457
  token && !loadingInitial && /* @__PURE__ */ jsxs3(Fragment, { children: [
432
- triggering ? /* @__PURE__ */ jsxs3(Flex3, { align: "center", gap: 1, children: [
433
- /* @__PURE__ */ jsx3(Spinner3, { muted: true }),
434
- /* @__PURE__ */ jsx3(Badge3, { tone: "caution", padding: 2, children: "Triggering\u2026" })
435
- ] }) : /* @__PURE__ */ jsx3(StatusBadge, { state: latest?.state, showSpinner: true }),
458
+ triggering ? /* @__PURE__ */ jsx3(Badge3, { tone: "caution", padding: 2, children: "Triggering\u2026" }) : /* @__PURE__ */ jsx3(StatusBadge, { state: latest?.state, showSpinner: true }),
436
459
  isActiveState(latest?.state) && /* @__PURE__ */ jsx3(
437
460
  Button2,
438
461
  {
@@ -530,15 +553,16 @@ function DeployItem({ target, token, onDelete, onEdit }) {
530
553
  {
531
554
  content: /* @__PURE__ */ jsx3(Box2, { padding: 2, children: /* @__PURE__ */ jsx3(Text2, { size: 1, children: commitMsg ?? sha }) }),
532
555
  portal: true,
533
- children: /* @__PURE__ */ jsx3(
534
- Text2,
556
+ children: commitHref ? /* @__PURE__ */ jsx3(
557
+ "a",
535
558
  {
536
- size: 1,
537
- muted: true,
538
- style: { cursor: "default", fontFamily: "monospace" },
539
- children: sha
559
+ href: commitHref,
560
+ target: "_blank",
561
+ rel: "noreferrer",
562
+ style: { color: "inherit", textDecoration: "none" },
563
+ children: /* @__PURE__ */ jsx3(Text2, { size: 1, muted: true, style: { cursor: "pointer", fontFamily: "monospace" }, children: sha })
540
564
  }
541
- )
565
+ ) : /* @__PURE__ */ jsx3(Text2, { size: 1, muted: true, style: { cursor: "default", fontFamily: "monospace" }, children: sha })
542
566
  }
543
567
  ),
544
568
  creator && /* @__PURE__ */ jsxs3(Text2, { size: 1, muted: true, children: [
@@ -601,7 +625,7 @@ function DeployItem({ target, token, onDelete, onEdit }) {
601
625
  ] }) })
602
626
  ] }),
603
627
  !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)) }),
628
+ /* @__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
629
  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
630
  /* @__PURE__ */ jsx3(LaunchIcon2, {}),
607
631
  /* @__PURE__ */ jsx3(Text2, { size: 1, children: "View full logs in Vercel" })
@@ -626,7 +650,7 @@ function DeployItem({ target, token, onDelete, onEdit }) {
626
650
  style: { width: "100%", justifyContent: "flex-start", borderRadius: 0, cursor: "pointer" }
627
651
  }
628
652
  ),
629
- showDetails && /* @__PURE__ */ jsx3(Card2, { tone: "primary", padding: 3, style: { borderRadius: 0 }, children: /* @__PURE__ */ jsxs3(Stack2, { space: 2, children: [
653
+ 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
654
  /* @__PURE__ */ jsxs3(Flex3, { gap: 2, align: "center", children: [
631
655
  /* @__PURE__ */ jsx3(Text2, { size: 0, muted: true, weight: "semibold", style: { minWidth: LABEL_WIDTH }, children: "Project" }),
632
656
  /* @__PURE__ */ jsx3(Text2, { size: 0, muted: true, style: { fontFamily: "monospace" }, children: projectId || "\u2014" })
@@ -910,7 +934,11 @@ function DeployTargetForm({ initial, onSaved, onClose }) {
910
934
  placeholder: "team_xxxxxxxx"
911
935
  }
912
936
  ),
913
- /* @__PURE__ */ jsx5(Text4, { size: 0, muted: true, children: "Required for team-owned Vercel projects." })
937
+ /* @__PURE__ */ jsxs5(Text4, { size: 0, muted: true, children: [
938
+ "Required for team-owned Vercel projects. Find it at Vercel \u2192 Settings \u2192 General \u2192 Team ID (starts with ",
939
+ /* @__PURE__ */ jsx5("code", { children: "team_" }),
940
+ ")."
941
+ ] })
914
942
  ] }),
915
943
  /* @__PURE__ */ jsxs5(Flex5, { align: "center", gap: 3, children: [
916
944
  /* @__PURE__ */ jsx5(
@@ -933,7 +961,7 @@ function DeployTargetForm({ initial, onSaved, onClose }) {
933
961
  }
934
962
 
935
963
  // src/version.ts
936
- var VERSION = "0.2.0";
964
+ var VERSION = "1.0.0";
937
965
 
938
966
  // src/components/DeployTool.tsx
939
967
  import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
@@ -941,7 +969,7 @@ var TOKEN_QUERY = `*[_id == "config.vercelDeploy"][0].accessToken`;
941
969
  var TARGETS_QUERY = `*[_type == "vercel_deploy"] | order(_createdAt asc)`;
942
970
  function DeployTool() {
943
971
  const client = useClient3({ apiVersion: "2025-01-01" });
944
- const toast = useToast();
972
+ const toast = useToast2();
945
973
  const [token, setToken] = useState5(null);
946
974
  const [targets, setTargets] = useState5([]);
947
975
  const [loading, setLoading] = useState5(true);
@@ -973,7 +1001,7 @@ function DeployTool() {
973
1001
  const style = document.createElement("style");
974
1002
  style.id = "dvfs-styles";
975
1003
  style.textContent = `
976
- @media (max-width: 600px) {
1004
+ @media (max-width: 768px) {
977
1005
  .dvfs-header { flex-wrap: wrap !important; row-gap: 8px !important; }
978
1006
  .dvfs-header-actions { width: 100% !important; flex-wrap: wrap !important; justify-content: flex-start !important; }
979
1007
  .dvfs-grid { grid-template-columns: 1fr !important; }
@@ -981,6 +1009,13 @@ function DeployTool() {
981
1009
  .dvfs-deploy-col { width: 100% !important; align-self: auto !important; }
982
1010
  .dvfs-deploy-col button { border-radius: 3px !important; }
983
1011
  }
1012
+ @keyframes dvfs-open {
1013
+ from { opacity: 0; transform: translateY(-4px); }
1014
+ to { opacity: 1; transform: translateY(0); }
1015
+ }
1016
+ .dvfs-accordion-content {
1017
+ animation: dvfs-open 0.15s ease-out;
1018
+ }
984
1019
  `;
985
1020
  document.head.appendChild(style);
986
1021
  return () => {
@@ -1079,7 +1114,7 @@ function DeployTool() {
1079
1114
  ] }) }),
1080
1115
  targets.length > 0 && /* @__PURE__ */ jsx6("div", { className: "dvfs-grid", style: {
1081
1116
  display: "grid",
1082
- gridTemplateColumns: "repeat(auto-fill, minmax(540px, 1fr))",
1117
+ gridTemplateColumns: "repeat(auto-fill, minmax(min(540px, 100%), 1fr))",
1083
1118
  gap: "16px",
1084
1119
  alignItems: "start"
1085
1120
  }, 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.1",
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",
@@ -1,15 +1,16 @@
1
1
  // Per-deploy-target card — shows status, build timer, history, cancel, deploy, copy URL, and error logs
2
2
  import { useState, useEffect, useCallback, useRef } from 'react'
3
+ import { flushSync } from 'react-dom'
3
4
  import {
4
5
  Card, Box, Stack, Flex, Text, Button, Tooltip, Badge, Spinner,
5
- MenuButton, Menu, MenuItem, Code,
6
+ MenuButton, Menu, MenuItem, Code, useToast,
6
7
  } from '@sanity/ui'
7
8
  import {
8
9
  ClockIcon, TrashIcon, EllipsisVerticalIcon, LaunchIcon,
9
10
  CopyIcon, CheckmarkIcon, WarningOutlineIcon, ChevronDownIcon, ChevronUpIcon, EditIcon, SchemaIcon
10
11
  } from '@sanity/icons'
11
12
  import { listDeployments, cancelDeployment, triggerDeploy, getDeploymentEvents } from '../lib/api'
12
- import { parseHookUrl, isActiveState, formatDuration, timeAgo, shortSha, safeHref, projectHref } from '../lib/helpers'
13
+ import { parseHookUrl, isActiveState, formatDuration, timeAgo, shortSha, safeHref, projectHref, githubCommitHref } from '../lib/helpers'
13
14
  import { StatusBadge } from './StatusBadge'
14
15
  import { DeployHistory } from './DeployHistory'
15
16
  import type { DeployTarget, VercelDeployment } from '../types'
@@ -26,6 +27,7 @@ interface DeployItemProps {
26
27
 
27
28
  export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps) {
28
29
  const { projectId, hookId } = parseHookUrl(target.url)
30
+ const toast = useToast()
29
31
 
30
32
  const [deployments, setDeployments] = useState<VercelDeployment[]>([])
31
33
  const [loadingInitial, setLoadingInitial] = useState(true)
@@ -69,6 +71,23 @@ export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps)
69
71
  if (triggering && latest && latest.state !== undefined) setTriggering(false)
70
72
  }, [triggering, latest])
71
73
 
74
+ // ── Deploy-complete toast ─────────────────────────────────────────────────
75
+ const prevStateRef = useRef<string | undefined>(undefined)
76
+ useEffect(() => {
77
+ const current = latest?.state
78
+ const prev = prevStateRef.current
79
+ if (prev && isActiveState(prev as never) && current && !isActiveState(current as never)) {
80
+ if (current === 'READY') {
81
+ toast.push({ status: 'success', title: `${target.name} deployed`, description: 'Build completed successfully' })
82
+ } else if (current === 'ERROR') {
83
+ toast.push({ status: 'error', title: `${target.name} failed`, description: 'Build encountered an error — check error details' })
84
+ } else if (current === 'CANCELED') {
85
+ toast.push({ status: 'warning', title: `${target.name} canceled`, description: 'Deployment was canceled' })
86
+ }
87
+ }
88
+ prevStateRef.current = current
89
+ }, [latest?.state, target.name, toast])
90
+
72
91
  useEffect(() => {
73
92
  setShowErrorLogs(false)
74
93
  setErrorLines([])
@@ -94,7 +113,7 @@ export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps)
94
113
  // ── Actions ───────────────────────────────────────────────────────────────
95
114
  const deploy = useCallback(async () => {
96
115
  setDeployError(null)
97
- setTriggering(true)
116
+ flushSync(() => setTriggering(true))
98
117
  try {
99
118
  await triggerDeploy(target.url)
100
119
  setTimeout(fetchDeployments, 2000)
@@ -119,11 +138,13 @@ export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps)
119
138
 
120
139
  const copyUrl = useCallback(() => {
121
140
  if (!latest?.url) return
122
- navigator.clipboard.writeText(`https://${latest.url}`).then(() => {
141
+ const fullUrl = `https://${latest.url}`
142
+ navigator.clipboard.writeText(fullUrl).then(() => {
123
143
  setCopied(true)
124
144
  setTimeout(() => setCopied(false), 2000)
125
- }).catch(err => {
126
- console.error('deploy-vercel-from-sanity: clipboard error', err)
145
+ }).catch(() => {
146
+ // Clipboard API unavailable — surface the URL for manual copy
147
+ window.prompt('Copy deployment URL:', fullUrl)
127
148
  })
128
149
  }, [latest?.url])
129
150
 
@@ -143,7 +164,7 @@ export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps)
143
164
  .filter(Boolean)
144
165
  .reverse()
145
166
  .slice(-30)
146
- setErrorLines(lines.length > 0 ? lines : ['No log output captured.'])
167
+ setErrorLines(lines.length > 0 ? lines : ['No stderr or stdout was captured for this build. Open the full build log in Vercel for details.'])
147
168
  } catch (err) {
148
169
  setLogError(err instanceof Error ? err.message : 'Failed to load build logs')
149
170
  } finally {
@@ -161,6 +182,7 @@ export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps)
161
182
  const commitMsg = latest?.meta?.githubCommitMessage?.split('\n')[0]
162
183
  const sha = shortSha(latest?.meta?.githubCommitSha)
163
184
  const fullSha = latest?.meta?.githubCommitSha
185
+ const commitHref = githubCommitHref(latest?.meta)
164
186
  const creator = latest?.creator?.username
165
187
  const deployedAt = latest?.created ? timeAgo(latest.created) : null
166
188
  const vercelProjectUrl = projectHref(latest?.inspectorUrl)
@@ -193,10 +215,7 @@ export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps)
193
215
  {token && !loadingInitial && (
194
216
  <>
195
217
  {triggering ? (
196
- <Flex align="center" gap={1}>
197
- <Spinner muted />
198
- <Badge tone="caution" padding={2}>Triggering…</Badge>
199
- </Flex>
218
+ <Badge tone="caution" padding={2}>Triggering…</Badge>
200
219
  ) : (
201
220
  <StatusBadge state={latest?.state} showSpinner />
202
221
  )}
@@ -304,7 +323,7 @@ export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps)
304
323
  </>
305
324
  )}
306
325
 
307
- {/* Commit SHA — tooltip shows full message */}
326
+ {/* Commit SHA — links to GitHub if repo info available, tooltip shows full message */}
308
327
  {sha && (
309
328
  <Tooltip
310
329
  content={
@@ -314,13 +333,22 @@ export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps)
314
333
  }
315
334
  portal
316
335
  >
317
- <Text
318
- size={1}
319
- muted
320
- style={{ cursor: 'default', fontFamily: 'monospace' }}
321
- >
322
- {sha}
323
- </Text>
336
+ {commitHref ? (
337
+ <a
338
+ href={commitHref}
339
+ target="_blank"
340
+ rel="noreferrer"
341
+ style={{ color: 'inherit', textDecoration: 'none' }}
342
+ >
343
+ <Text size={1} muted style={{ cursor: 'pointer', fontFamily: 'monospace' }}>
344
+ {sha}
345
+ </Text>
346
+ </a>
347
+ ) : (
348
+ <Text size={1} muted style={{ cursor: 'default', fontFamily: 'monospace' }}>
349
+ {sha}
350
+ </Text>
351
+ )}
324
352
  </Tooltip>
325
353
  )}
326
354
 
@@ -399,7 +427,7 @@ export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps)
399
427
  )}
400
428
  {!loadingLogs && !logError && errorLines.length > 0 && (
401
429
  <Stack space={2}>
402
- <Box style={{ maxHeight: 240, overflowY: 'auto', fontFamily: 'monospace', fontSize: 11, lineHeight: 1.6 }}>
430
+ <Box style={{ maxHeight: 240, overflowY: 'auto', fontFamily: 'monospace', fontSize: 13, lineHeight: 1.6 }}>
403
431
  {errorLines.map((line, i) => (
404
432
  <Code key={i} size={1} style={{ display: 'block', whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
405
433
  {line}
@@ -445,7 +473,7 @@ export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps)
445
473
  style={{ width: '100%', justifyContent: 'flex-start', borderRadius: 0, cursor: 'pointer' }}
446
474
  />
447
475
  {showDetails && (
448
- <Card tone="primary" padding={3} style={{ borderRadius: 0 }}>
476
+ <Card tone="primary" padding={3} className="dvfs-accordion-content" style={{ borderRadius: 0, borderTop: '1px solid rgba(128,128,128,0.15)' }}>
449
477
  <Stack space={2}>
450
478
  <Flex gap={2} align="center">
451
479
  <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'