@liiift-studio/deploy-vercel-from-sanity 0.2.1 → 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);
@@ -412,8 +436,20 @@ function DeployItem({ target, token, onDelete, onEdit }) {
412
436
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_ui3.Spinner, { muted: true }),
413
437
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_ui3.Badge, { tone: "caution", padding: 2, children: "Triggering\u2026" })
414
438
  ] }) : /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(StatusBadge, { state: latest?.state, showSpinner: true }),
439
+ isActiveState(latest?.state) && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
440
+ import_ui3.Button,
441
+ {
442
+ text: "Cancel",
443
+ mode: "ghost",
444
+ tone: "critical",
445
+ loading: canceling,
446
+ disabled: canceling,
447
+ onClick: cancel,
448
+ style: { cursor: "pointer" }
449
+ }
450
+ ),
415
451
  isActive && elapsed > 0 ? /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_ui3.Flex, { align: "center", gap: 1, children: [
416
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_icons2.ClockIcon, {}),
452
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_ui3.Spinner, { muted: true }),
417
453
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_ui3.Text, { size: 1, muted: true, children: formatDuration(elapsed) })
418
454
  ] }) : !isActive && deployedAt ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_ui3.Text, { size: 1, muted: true, children: deployedAt }) : null
419
455
  ] })
@@ -497,15 +533,16 @@ function DeployItem({ target, token, onDelete, onEdit }) {
497
533
  {
498
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 }) }),
499
535
  portal: true,
500
- children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
501
- import_ui3.Text,
536
+ children: commitHref ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
537
+ "a",
502
538
  {
503
- size: 1,
504
- muted: true,
505
- style: { cursor: "default", fontFamily: "monospace" },
506
- 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 })
507
544
  }
508
- )
545
+ ) : /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_ui3.Text, { size: 1, muted: true, style: { cursor: "default", fontFamily: "monospace" }, children: sha })
509
546
  }
510
547
  ),
511
548
  creator && /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_ui3.Text, { size: 1, muted: true, children: [
@@ -568,7 +605,7 @@ function DeployItem({ target, token, onDelete, onEdit }) {
568
605
  ] }) })
569
606
  ] }),
570
607
  !loadingLogs && !logError && errorLines.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_ui3.Stack, { space: 2, children: [
571
- /* @__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)) }),
572
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: [
573
610
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_icons2.LaunchIcon, {}),
574
611
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_ui3.Text, { size: 1, children: "View full logs in Vercel" })
@@ -593,7 +630,7 @@ function DeployItem({ target, token, onDelete, onEdit }) {
593
630
  style: { width: "100%", justifyContent: "flex-start", borderRadius: 0, cursor: "pointer" }
594
631
  }
595
632
  ),
596
- 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: [
597
634
  /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_ui3.Flex, { gap: 2, align: "center", children: [
598
635
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_ui3.Text, { size: 0, muted: true, weight: "semibold", style: { minWidth: LABEL_WIDTH }, children: "Project" }),
599
636
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_ui3.Text, { size: 0, muted: true, style: { fontFamily: "monospace" }, children: projectId || "\u2014" })
@@ -640,44 +677,30 @@ function DeployItem({ target, token, onDelete, onEdit }) {
640
677
  ] }) })
641
678
  ] })
642
679
  ] }),
643
- /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(
680
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
644
681
  import_ui3.Flex,
645
682
  {
646
683
  direction: "column",
647
684
  gap: 2,
648
685
  className: "dvfs-deploy-col",
649
686
  style: { flexShrink: 0, alignSelf: "stretch" },
650
- children: [
651
- isActiveState(latest?.state) && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
652
- import_ui3.Button,
653
- {
654
- text: "Cancel",
655
- mode: "ghost",
656
- tone: "critical",
657
- loading: canceling,
658
- disabled: canceling,
659
- onClick: cancel,
660
- style: { cursor: "pointer" }
661
- }
662
- ),
663
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
664
- import_ui3.Button,
665
- {
666
- text: "Deploy",
667
- tone: "primary",
668
- loading: triggering,
669
- disabled: isActive,
670
- onClick: deploy,
671
- style: {
672
- flex: 1,
673
- borderRadius: 0,
674
- borderTopRightRadius: 3,
675
- borderBottomRightRadius: 3,
676
- cursor: "pointer"
677
- }
687
+ children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
688
+ import_ui3.Button,
689
+ {
690
+ text: "Deploy",
691
+ tone: "primary",
692
+ loading: triggering,
693
+ disabled: isActive,
694
+ onClick: deploy,
695
+ style: {
696
+ flex: 1,
697
+ borderRadius: 0,
698
+ borderTopRightRadius: 3,
699
+ borderBottomRightRadius: 3,
700
+ cursor: "pointer"
678
701
  }
679
- )
680
- ]
702
+ }
703
+ )
681
704
  }
682
705
  )
683
706
  ] }) }),
@@ -880,7 +903,11 @@ function DeployTargetForm({ initial, onSaved, onClose }) {
880
903
  placeholder: "team_xxxxxxxx"
881
904
  }
882
905
  ),
883
- /* @__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
+ ] })
884
911
  ] }),
885
912
  /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)(import_ui5.Flex, { align: "center", gap: 3, children: [
886
913
  /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(
@@ -903,7 +930,7 @@ function DeployTargetForm({ initial, onSaved, onClose }) {
903
930
  }
904
931
 
905
932
  // src/version.ts
906
- var VERSION = "0.2.0";
933
+ var VERSION = "1.0.0";
907
934
 
908
935
  // src/components/DeployTool.tsx
909
936
  var import_jsx_runtime6 = require("react/jsx-runtime");
@@ -943,7 +970,7 @@ function DeployTool() {
943
970
  const style = document.createElement("style");
944
971
  style.id = "dvfs-styles";
945
972
  style.textContent = `
946
- @media (max-width: 600px) {
973
+ @media (max-width: 768px) {
947
974
  .dvfs-header { flex-wrap: wrap !important; row-gap: 8px !important; }
948
975
  .dvfs-header-actions { width: 100% !important; flex-wrap: wrap !important; justify-content: flex-start !important; }
949
976
  .dvfs-grid { grid-template-columns: 1fr !important; }
@@ -951,6 +978,13 @@ function DeployTool() {
951
978
  .dvfs-deploy-col { width: 100% !important; align-self: auto !important; }
952
979
  .dvfs-deploy-col button { border-radius: 3px !important; }
953
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
+ }
954
988
  `;
955
989
  document.head.appendChild(style);
956
990
  return () => {
@@ -1049,7 +1083,7 @@ function DeployTool() {
1049
1083
  ] }) }),
1050
1084
  targets.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("div", { className: "dvfs-grid", style: {
1051
1085
  display: "grid",
1052
- gridTemplateColumns: "repeat(auto-fill, minmax(540px, 1fr))",
1086
+ gridTemplateColumns: "repeat(auto-fill, minmax(min(540px, 100%), 1fr))",
1053
1087
  gap: "16px",
1054
1088
  alignItems: "start"
1055
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);
@@ -433,8 +458,20 @@ function DeployItem({ target, token, onDelete, onEdit }) {
433
458
  /* @__PURE__ */ jsx3(Spinner3, { muted: true }),
434
459
  /* @__PURE__ */ jsx3(Badge3, { tone: "caution", padding: 2, children: "Triggering\u2026" })
435
460
  ] }) : /* @__PURE__ */ jsx3(StatusBadge, { state: latest?.state, showSpinner: true }),
461
+ isActiveState(latest?.state) && /* @__PURE__ */ jsx3(
462
+ Button2,
463
+ {
464
+ text: "Cancel",
465
+ mode: "ghost",
466
+ tone: "critical",
467
+ loading: canceling,
468
+ disabled: canceling,
469
+ onClick: cancel,
470
+ style: { cursor: "pointer" }
471
+ }
472
+ ),
436
473
  isActive && elapsed > 0 ? /* @__PURE__ */ jsxs3(Flex3, { align: "center", gap: 1, children: [
437
- /* @__PURE__ */ jsx3(ClockIcon, {}),
474
+ /* @__PURE__ */ jsx3(Spinner3, { muted: true }),
438
475
  /* @__PURE__ */ jsx3(Text2, { size: 1, muted: true, children: formatDuration(elapsed) })
439
476
  ] }) : !isActive && deployedAt ? /* @__PURE__ */ jsx3(Text2, { size: 1, muted: true, children: deployedAt }) : null
440
477
  ] })
@@ -518,15 +555,16 @@ function DeployItem({ target, token, onDelete, onEdit }) {
518
555
  {
519
556
  content: /* @__PURE__ */ jsx3(Box2, { padding: 2, children: /* @__PURE__ */ jsx3(Text2, { size: 1, children: commitMsg ?? sha }) }),
520
557
  portal: true,
521
- children: /* @__PURE__ */ jsx3(
522
- Text2,
558
+ children: commitHref ? /* @__PURE__ */ jsx3(
559
+ "a",
523
560
  {
524
- size: 1,
525
- muted: true,
526
- style: { cursor: "default", fontFamily: "monospace" },
527
- 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 })
528
566
  }
529
- )
567
+ ) : /* @__PURE__ */ jsx3(Text2, { size: 1, muted: true, style: { cursor: "default", fontFamily: "monospace" }, children: sha })
530
568
  }
531
569
  ),
532
570
  creator && /* @__PURE__ */ jsxs3(Text2, { size: 1, muted: true, children: [
@@ -589,7 +627,7 @@ function DeployItem({ target, token, onDelete, onEdit }) {
589
627
  ] }) })
590
628
  ] }),
591
629
  !loadingLogs && !logError && errorLines.length > 0 && /* @__PURE__ */ jsxs3(Stack2, { space: 2, children: [
592
- /* @__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)) }),
593
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: [
594
632
  /* @__PURE__ */ jsx3(LaunchIcon2, {}),
595
633
  /* @__PURE__ */ jsx3(Text2, { size: 1, children: "View full logs in Vercel" })
@@ -614,7 +652,7 @@ function DeployItem({ target, token, onDelete, onEdit }) {
614
652
  style: { width: "100%", justifyContent: "flex-start", borderRadius: 0, cursor: "pointer" }
615
653
  }
616
654
  ),
617
- 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: [
618
656
  /* @__PURE__ */ jsxs3(Flex3, { gap: 2, align: "center", children: [
619
657
  /* @__PURE__ */ jsx3(Text2, { size: 0, muted: true, weight: "semibold", style: { minWidth: LABEL_WIDTH }, children: "Project" }),
620
658
  /* @__PURE__ */ jsx3(Text2, { size: 0, muted: true, style: { fontFamily: "monospace" }, children: projectId || "\u2014" })
@@ -661,44 +699,30 @@ function DeployItem({ target, token, onDelete, onEdit }) {
661
699
  ] }) })
662
700
  ] })
663
701
  ] }),
664
- /* @__PURE__ */ jsxs3(
702
+ /* @__PURE__ */ jsx3(
665
703
  Flex3,
666
704
  {
667
705
  direction: "column",
668
706
  gap: 2,
669
707
  className: "dvfs-deploy-col",
670
708
  style: { flexShrink: 0, alignSelf: "stretch" },
671
- children: [
672
- isActiveState(latest?.state) && /* @__PURE__ */ jsx3(
673
- Button2,
674
- {
675
- text: "Cancel",
676
- mode: "ghost",
677
- tone: "critical",
678
- loading: canceling,
679
- disabled: canceling,
680
- onClick: cancel,
681
- style: { cursor: "pointer" }
682
- }
683
- ),
684
- /* @__PURE__ */ jsx3(
685
- Button2,
686
- {
687
- text: "Deploy",
688
- tone: "primary",
689
- loading: triggering,
690
- disabled: isActive,
691
- onClick: deploy,
692
- style: {
693
- flex: 1,
694
- borderRadius: 0,
695
- borderTopRightRadius: 3,
696
- borderBottomRightRadius: 3,
697
- cursor: "pointer"
698
- }
709
+ children: /* @__PURE__ */ jsx3(
710
+ Button2,
711
+ {
712
+ text: "Deploy",
713
+ tone: "primary",
714
+ loading: triggering,
715
+ disabled: isActive,
716
+ onClick: deploy,
717
+ style: {
718
+ flex: 1,
719
+ borderRadius: 0,
720
+ borderTopRightRadius: 3,
721
+ borderBottomRightRadius: 3,
722
+ cursor: "pointer"
699
723
  }
700
- )
701
- ]
724
+ }
725
+ )
702
726
  }
703
727
  )
704
728
  ] }) }),
@@ -912,7 +936,11 @@ function DeployTargetForm({ initial, onSaved, onClose }) {
912
936
  placeholder: "team_xxxxxxxx"
913
937
  }
914
938
  ),
915
- /* @__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
+ ] })
916
944
  ] }),
917
945
  /* @__PURE__ */ jsxs5(Flex5, { align: "center", gap: 3, children: [
918
946
  /* @__PURE__ */ jsx5(
@@ -935,7 +963,7 @@ function DeployTargetForm({ initial, onSaved, onClose }) {
935
963
  }
936
964
 
937
965
  // src/version.ts
938
- var VERSION = "0.2.0";
966
+ var VERSION = "1.0.0";
939
967
 
940
968
  // src/components/DeployTool.tsx
941
969
  import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
@@ -943,7 +971,7 @@ var TOKEN_QUERY = `*[_id == "config.vercelDeploy"][0].accessToken`;
943
971
  var TARGETS_QUERY = `*[_type == "vercel_deploy"] | order(_createdAt asc)`;
944
972
  function DeployTool() {
945
973
  const client = useClient3({ apiVersion: "2025-01-01" });
946
- const toast = useToast();
974
+ const toast = useToast2();
947
975
  const [token, setToken] = useState5(null);
948
976
  const [targets, setTargets] = useState5([]);
949
977
  const [loading, setLoading] = useState5(true);
@@ -975,7 +1003,7 @@ function DeployTool() {
975
1003
  const style = document.createElement("style");
976
1004
  style.id = "dvfs-styles";
977
1005
  style.textContent = `
978
- @media (max-width: 600px) {
1006
+ @media (max-width: 768px) {
979
1007
  .dvfs-header { flex-wrap: wrap !important; row-gap: 8px !important; }
980
1008
  .dvfs-header-actions { width: 100% !important; flex-wrap: wrap !important; justify-content: flex-start !important; }
981
1009
  .dvfs-grid { grid-template-columns: 1fr !important; }
@@ -983,6 +1011,13 @@ function DeployTool() {
983
1011
  .dvfs-deploy-col { width: 100% !important; align-self: auto !important; }
984
1012
  .dvfs-deploy-col button { border-radius: 3px !important; }
985
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
+ }
986
1021
  `;
987
1022
  document.head.appendChild(style);
988
1023
  return () => {
@@ -1081,7 +1116,7 @@ function DeployTool() {
1081
1116
  ] }) }),
1082
1117
  targets.length > 0 && /* @__PURE__ */ jsx6("div", { className: "dvfs-grid", style: {
1083
1118
  display: "grid",
1084
- gridTemplateColumns: "repeat(auto-fill, minmax(540px, 1fr))",
1119
+ gridTemplateColumns: "repeat(auto-fill, minmax(min(540px, 100%), 1fr))",
1085
1120
  gap: "16px",
1086
1121
  alignItems: "start"
1087
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.1",
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)
@@ -200,9 +221,20 @@ export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps)
200
221
  ) : (
201
222
  <StatusBadge state={latest?.state} showSpinner />
202
223
  )}
224
+ {isActiveState(latest?.state) && (
225
+ <Button
226
+ text="Cancel"
227
+ mode="ghost"
228
+ tone="critical"
229
+ loading={canceling}
230
+ disabled={canceling}
231
+ onClick={cancel}
232
+ style={{ cursor: 'pointer' }}
233
+ />
234
+ )}
203
235
  {isActive && elapsed > 0 ? (
204
236
  <Flex align="center" gap={1}>
205
- <ClockIcon />
237
+ <Spinner muted />
206
238
  <Text size={1} muted>{formatDuration(elapsed)}</Text>
207
239
  </Flex>
208
240
  ) : (!isActive && deployedAt) ? (
@@ -293,7 +325,7 @@ export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps)
293
325
  </>
294
326
  )}
295
327
 
296
- {/* Commit SHA — tooltip shows full message */}
328
+ {/* Commit SHA — links to GitHub if repo info available, tooltip shows full message */}
297
329
  {sha && (
298
330
  <Tooltip
299
331
  content={
@@ -303,13 +335,22 @@ export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps)
303
335
  }
304
336
  portal
305
337
  >
306
- <Text
307
- size={1}
308
- muted
309
- style={{ cursor: 'default', fontFamily: 'monospace' }}
310
- >
311
- {sha}
312
- </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
+ )}
313
354
  </Tooltip>
314
355
  )}
315
356
 
@@ -388,7 +429,7 @@ export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps)
388
429
  )}
389
430
  {!loadingLogs && !logError && errorLines.length > 0 && (
390
431
  <Stack space={2}>
391
- <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 }}>
392
433
  {errorLines.map((line, i) => (
393
434
  <Code key={i} size={1} style={{ display: 'block', whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
394
435
  {line}
@@ -434,7 +475,7 @@ export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps)
434
475
  style={{ width: '100%', justifyContent: 'flex-start', borderRadius: 0, cursor: 'pointer' }}
435
476
  />
436
477
  {showDetails && (
437
- <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)' }}>
438
479
  <Stack space={2}>
439
480
  <Flex gap={2} align="center">
440
481
  <Text size={0} muted weight="semibold" style={{ minWidth: LABEL_WIDTH }}>Project</Text>
@@ -511,17 +552,6 @@ export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps)
511
552
  className="dvfs-deploy-col"
512
553
  style={{ flexShrink: 0, alignSelf: 'stretch' }}
513
554
  >
514
- {isActiveState(latest?.state) && (
515
- <Button
516
- text="Cancel"
517
- mode="ghost"
518
- tone="critical"
519
- loading={canceling}
520
- disabled={canceling}
521
- onClick={cancel}
522
- style={{ cursor: 'pointer' }}
523
- />
524
- )}
525
555
  <Button
526
556
  text="Deploy"
527
557
  tone="primary"
@@ -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'