@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.
- package/README.md +92 -70
- package/dist/index.js +303 -111
- package/dist/index.mjs +312 -115
- package/package.json +1 -1
- package/src/components/DeployItem.tsx +334 -155
- package/src/components/DeployTool.tsx +34 -27
- package/src/components/TokenSetup.tsx +1 -1
- package/src/lib/api.ts +33 -2
- package/src/lib/helpers.ts +25 -8
- package/src/types.ts +11 -3
|
@@ -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 == "
|
|
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
|
|
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
|
-
<
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
|
125
|
-
hook URL
|
|
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
|
-
{
|
|
133
|
-
<
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
|
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 = '
|
|
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
|
-
/**
|
|
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
|
+
}
|
package/src/lib/helpers.ts
CHANGED
|
@@ -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':
|
|
78
|
-
case 'BUILDING':
|
|
79
|
-
case 'QUEUED':
|
|
80
|
-
case 'INITIALIZING':return { label: 'Initializing', tone: 'caution' }
|
|
81
|
-
case 'ERROR':
|
|
82
|
-
case 'CANCELED':
|
|
83
|
-
case 'LOADING':
|
|
84
|
-
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
|
-
/**
|
|
49
|
-
export interface
|
|
50
|
-
|
|
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
|
}
|