@liiift-studio/deploy-vercel-from-sanity 0.1.3 → 0.1.6

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.
@@ -2,14 +2,14 @@
2
2
  import { useState, useEffect, useCallback } from 'react'
3
3
  import { useClient } from 'sanity'
4
4
  import {
5
- Card, Box, Stack, Flex, Text, Heading, Spinner, Button, Dialog, useToast,
5
+ Card, Box, Stack, Flex, Text, Heading, Spinner, Button, Badge, Dialog, useToast,
6
6
  } from '@sanity/ui'
7
7
  import { RocketIcon, TokenIcon, TrashIcon, WarningOutlineIcon } from '@sanity/icons'
8
8
  import { DeployItem } from './DeployItem'
9
9
  import { TokenSetup } from './TokenSetup'
10
10
  import type { DeployTarget } from '../types'
11
11
 
12
- const TOKEN_QUERY = `*[_id == "secrets.vercelDeploy"][0].accessToken`
12
+ const TOKEN_QUERY = `*[_id == "config.vercelDeploy"][0].accessToken`
13
13
  const TARGETS_QUERY = `*[_type == "vercel_deploy"] | order(_createdAt asc)`
14
14
 
15
15
  export function DeployTool() {
@@ -81,11 +81,7 @@ export function DeployTool() {
81
81
  )
82
82
  }
83
83
 
84
- if (!token && !showTokenSetup) {
85
- return <TokenSetup onSaved={() => { setShowTokenSetup(false); load() }} />
86
- }
87
-
88
- if (showTokenSetup) {
84
+ if (!token || showTokenSetup) {
89
85
  return (
90
86
  <TokenSetup
91
87
  onSaved={() => {
@@ -106,13 +102,16 @@ export function DeployTool() {
106
102
  <RocketIcon />
107
103
  <Heading size={2}>Deploy</Heading>
108
104
  </Flex>
109
- <Button
110
- text="API Token"
111
- mode="ghost"
112
- icon={TokenIcon}
113
- fontSize={1}
114
- onClick={() => setShowTokenSetup(true)}
115
- />
105
+ <Flex align="center" gap={3}>
106
+ <Badge tone="positive" mode="outline">Connected</Badge>
107
+ <Button
108
+ text="Change Token"
109
+ mode="ghost"
110
+ icon={TokenIcon}
111
+ fontSize={1}
112
+ onClick={() => setShowTokenSetup(true)}
113
+ />
114
+ </Flex>
116
115
  </Flex>
117
116
 
118
117
  {/* ── No targets ──────────────────────────────────────────── */}
@@ -121,22 +120,30 @@ export function DeployTool() {
121
120
  <Stack space={3} style={{ textAlign: 'center' }}>
122
121
  <Text size={2} weight="semibold">No deploy targets configured</Text>
123
122
  <Text size={1} muted>
124
- Create a <code>vercel_deploy</code> document in the dataset with a Vercel deploy
125
- hook URL, or add one via the Sanity CLI.
123
+ Create a <code>vercel_deploy</code> document in the dataset with a Vercel
124
+ deploy hook URL.
126
125
  </Text>
127
126
  </Stack>
128
127
  </Card>
129
128
  )}
130
129
 
131
- {/* ── Deploy targets ──────────────────────────────────────── */}
132
- {token && targets.map(target => (
133
- <DeployItem
134
- key={target._id}
135
- target={target}
136
- token={token}
137
- onDelete={setPendingDelete}
138
- />
139
- ))}
130
+ {/* ── Deploy targets responsive 2-col grid ──────────────── */}
131
+ {targets.length > 0 && (
132
+ <div style={{
133
+ display: 'grid',
134
+ gridTemplateColumns: 'repeat(auto-fill, minmax(380px, 1fr))',
135
+ gap: '12px',
136
+ }}>
137
+ {targets.map(target => (
138
+ <DeployItem
139
+ key={target._id}
140
+ target={target}
141
+ token={token}
142
+ onDelete={setPendingDelete}
143
+ />
144
+ ))}
145
+ </div>
146
+ )}
140
147
  </Stack>
141
148
  </Box>
142
149
 
@@ -172,8 +179,8 @@ export function DeployTool() {
172
179
  <Text size={2} weight="semibold">{pendingDelete.name}</Text>
173
180
  </Flex>
174
181
  <Text size={1} muted>
175
- This removes the deploy target from the dataset. The Vercel deploy hook itself is
176
- not affected.
182
+ This removes the deploy target from the dataset. The Vercel deploy hook
183
+ itself is not affected.
177
184
  </Text>
178
185
  </Stack>
179
186
  </Box>
@@ -11,7 +11,7 @@ interface TokenSetupProps {
11
11
  onSaved: () => void
12
12
  }
13
13
 
14
- const TOKEN_DOC_ID = 'secrets.vercelDeploy'
14
+ const TOKEN_DOC_ID = 'config.vercelDeploy'
15
15
 
16
16
  export function TokenSetup({ onSaved }: TokenSetupProps) {
17
17
  const client = useClient({ apiVersion: '2025-01-01' })
package/src/lib/api.ts CHANGED
@@ -1,8 +1,11 @@
1
1
  // Vercel REST API helpers — all calls require a bearer token
2
- import type { VercelDeployment } from '../types'
2
+ import type { VercelDeployment, DeploymentEvent } from '../types'
3
3
 
4
4
  const BASE = 'https://api.vercel.com'
5
5
 
6
+ /** Only allow genuine Vercel deploy hook URLs through triggerDeploy */
7
+ const VERCEL_HOOK_RE = /^https:\/\/api\.vercel\.com\/v1\/integrations\/deploy\//
8
+
6
9
  async function vercelFetch<T>(path: string, token: string, init?: RequestInit): Promise<T> {
7
10
  const res = await fetch(`${BASE}${path}`, {
8
11
  ...init,
@@ -52,8 +55,36 @@ export async function cancelDeployment(opts: {
52
55
  })
53
56
  }
54
57
 
55
- /** Trigger a deploy by POSTing to the hook URL — no auth needed */
58
+ /**
59
+ * Trigger a deploy by POSTing to the hook URL.
60
+ * Validates the URL is a genuine Vercel hook before calling to prevent
61
+ * SSRF if a document is tampered with outside the Studio schema.
62
+ */
56
63
  export async function triggerDeploy(hookUrl: string): Promise<void> {
64
+ if (!VERCEL_HOOK_RE.test(hookUrl)) {
65
+ throw new Error('Invalid deploy hook URL — must be a Vercel hook (api.vercel.com/v1/integrations/deploy/…)')
66
+ }
57
67
  const res = await fetch(hookUrl, { method: 'POST' })
58
68
  if (!res.ok) throw new Error(`Deploy hook returned ${res.status}`)
59
69
  }
70
+
71
+ /**
72
+ * Fetch build events for a deployment.
73
+ * Returns up to 100 events in reverse chronological order,
74
+ * filtered to lines with actual text content.
75
+ */
76
+ export async function getDeploymentEvents(opts: {
77
+ deploymentId: string
78
+ token: string
79
+ teamId?: string
80
+ }): Promise<DeploymentEvent[]> {
81
+ const params = new URLSearchParams({ limit: '100', direction: 'backward' })
82
+ if (opts.teamId) params.set('teamId', opts.teamId)
83
+ // API returns either a plain array or a wrapped object depending on version
84
+ const raw = await vercelFetch<DeploymentEvent[] | { events?: DeploymentEvent[] }>(
85
+ `/v2/deployments/${opts.deploymentId}/events?${params}`,
86
+ opts.token,
87
+ )
88
+ const events: DeploymentEvent[] = Array.isArray(raw) ? raw : (raw.events ?? [])
89
+ return events.filter(e => e.text?.trim())
90
+ }
@@ -74,13 +74,30 @@ export function stateLabel(state: VercelDeployState | undefined): {
74
74
  tone: 'positive' | 'caution' | 'critical' | 'default'
75
75
  } {
76
76
  switch (state) {
77
- case 'READY': return { label: 'Ready', tone: 'positive' }
78
- case 'BUILDING': return { label: 'Building', tone: 'caution' }
79
- case 'QUEUED': return { label: 'Queued', tone: 'caution' }
80
- case 'INITIALIZING':return { label: 'Initializing', tone: 'caution' }
81
- case 'ERROR': return { label: 'Error', tone: 'critical' }
82
- case 'CANCELED': return { label: 'Canceled', tone: 'default' }
83
- case 'LOADING': return { label: 'Loading…', tone: 'default' }
84
- default: return { label: 'Unknown', tone: 'default' }
77
+ case 'READY': return { label: 'Ready', tone: 'positive' }
78
+ case 'BUILDING': return { label: 'Building', tone: 'caution' }
79
+ case 'QUEUED': return { label: 'Queued', tone: 'caution' }
80
+ case 'INITIALIZING': return { label: 'Initializing', tone: 'caution' }
81
+ case 'ERROR': return { label: 'Error', tone: 'critical' }
82
+ case 'CANCELED': return { label: 'Canceled', tone: 'default' }
83
+ case 'LOADING': return { label: 'Loading…', tone: 'default' }
84
+ default: return { label: 'Unknown', tone: 'default' }
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Extracts the Vercel project dashboard URL from a deployment's inspectorUrl.
90
+ * inspectorUrl format: https://vercel.com/{team}/{project}/{deploymentId}
91
+ * Returns https://vercel.com/{team}/{project} or null if unparseable.
92
+ */
93
+ export function projectHref(inspectorUrl: string | undefined): string | null {
94
+ if (!inspectorUrl) return null
95
+ try {
96
+ const { origin, pathname } = new URL(inspectorUrl)
97
+ const parts = pathname.split('/').filter(Boolean)
98
+ if (parts.length < 2) return null
99
+ return `${origin}/${parts[0]}/${parts[1]}`
100
+ } catch {
101
+ return null
85
102
  }
86
103
  }
package/src/types.ts CHANGED
@@ -45,9 +45,17 @@ export interface VercelDeployment {
45
45
  }
46
46
  }
47
47
 
48
- /** Vercel secrets document stored at _id: 'secrets.vercelDeploy' */
49
- export interface VercelSecrets {
50
- _id: 'secrets.vercelDeploy'
48
+ /** A single build event returned by GET /v2/deployments/{id}/events */
49
+ export interface DeploymentEvent {
50
+ type: 'command' | 'stdout' | 'stderr' | 'exit' | 'deployment-state'
51
+ text?: string
52
+ created: number
53
+ payload?: Record<string, unknown>
54
+ }
55
+
56
+ /** Vercel config document stored at _id: 'config.vercelDeploy' — readable by all authenticated users */
57
+ export interface VercelConfig {
58
+ _id: 'config.vercelDeploy'
51
59
  _type: 'vercelDeploy.config'
52
60
  accessToken: string
53
61
  }