@liiift-studio/deploy-vercel-from-sanity 0.1.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 +139 -0
- package/dist/index.d.mts +75 -0
- package/dist/index.d.ts +75 -0
- package/dist/index.js +768 -0
- package/dist/index.mjs +791 -0
- package/package.json +42 -0
- package/src/components/DeployHistory.tsx +157 -0
- package/src/components/DeployItem.tsx +298 -0
- package/src/components/DeployTool.tsx +184 -0
- package/src/components/StatusBadge.tsx +23 -0
- package/src/components/TokenSetup.tsx +92 -0
- package/src/index.ts +42 -0
- package/src/lib/api.ts +59 -0
- package/src/lib/helpers.ts +86 -0
- package/src/schema/vercelDeploy.ts +49 -0
- package/src/types.ts +63 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Status badge for a Vercel deployment state
|
|
2
|
+
import { Badge, Spinner, Flex } from '@sanity/ui'
|
|
3
|
+
import { stateLabel } from '../lib/helpers'
|
|
4
|
+
import type { VercelDeployState } from '../types'
|
|
5
|
+
|
|
6
|
+
interface StatusBadgeProps {
|
|
7
|
+
state: VercelDeployState | undefined
|
|
8
|
+
/** When true, shows a spinner alongside the label */
|
|
9
|
+
showSpinner?: boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function StatusBadge({ state, showSpinner }: StatusBadgeProps) {
|
|
13
|
+
const { label, tone } = stateLabel(state)
|
|
14
|
+
const spinning = showSpinner && (state === 'QUEUED' || state === 'INITIALIZING' || state === 'BUILDING')
|
|
15
|
+
return (
|
|
16
|
+
<Flex align="center" gap={2}>
|
|
17
|
+
{spinning && <Spinner muted />}
|
|
18
|
+
<Badge tone={tone} mode="outline">
|
|
19
|
+
{label}
|
|
20
|
+
</Badge>
|
|
21
|
+
</Flex>
|
|
22
|
+
)
|
|
23
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Vercel API token setup — stores token in secrets.vercelDeploy Sanity document
|
|
2
|
+
import { useState, useCallback } from 'react'
|
|
3
|
+
import { useClient } from 'sanity'
|
|
4
|
+
import {
|
|
5
|
+
Card, Box, Stack, Text, Heading, TextInput, Button, Flex,
|
|
6
|
+
} from '@sanity/ui'
|
|
7
|
+
import { KeyIcon, CheckmarkCircleIcon } from '@sanity/icons'
|
|
8
|
+
|
|
9
|
+
interface TokenSetupProps {
|
|
10
|
+
/** Called after the token is successfully saved */
|
|
11
|
+
onSaved: () => void
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const TOKEN_DOC_ID = 'secrets.vercelDeploy'
|
|
15
|
+
|
|
16
|
+
export function TokenSetup({ onSaved }: TokenSetupProps) {
|
|
17
|
+
const client = useClient({ apiVersion: '2025-01-01' })
|
|
18
|
+
const [token, setToken] = useState('')
|
|
19
|
+
const [saving, setSaving] = useState(false)
|
|
20
|
+
const [error, setError] = useState<string | null>(null)
|
|
21
|
+
|
|
22
|
+
const save = useCallback(async () => {
|
|
23
|
+
if (!token.trim()) return
|
|
24
|
+
setSaving(true)
|
|
25
|
+
setError(null)
|
|
26
|
+
try {
|
|
27
|
+
await client.createOrReplace({
|
|
28
|
+
_id: TOKEN_DOC_ID,
|
|
29
|
+
_type: 'vercelDeploy.config',
|
|
30
|
+
accessToken: token.trim(),
|
|
31
|
+
})
|
|
32
|
+
onSaved()
|
|
33
|
+
} catch (err) {
|
|
34
|
+
setError(err instanceof Error ? err.message : 'Failed to save token')
|
|
35
|
+
} finally {
|
|
36
|
+
setSaving(false)
|
|
37
|
+
}
|
|
38
|
+
}, [client, token, onSaved])
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<Card height="fill" tone="transparent">
|
|
42
|
+
<Flex align="center" justify="center" height="fill" padding={6}>
|
|
43
|
+
<Card padding={5} radius={3} shadow={1} style={{ maxWidth: 480, width: '100%' }}>
|
|
44
|
+
<Stack space={5}>
|
|
45
|
+
<Flex align="center" gap={3}>
|
|
46
|
+
<Text size={3}><KeyIcon /></Text>
|
|
47
|
+
<Heading size={2}>Connect to Vercel</Heading>
|
|
48
|
+
</Flex>
|
|
49
|
+
|
|
50
|
+
<Stack space={3}>
|
|
51
|
+
<Text size={1} muted>
|
|
52
|
+
A Vercel API token is required to read deployment status, history, and build logs.
|
|
53
|
+
Your token is stored securely in the Sanity dataset under a{' '}
|
|
54
|
+
<code>secrets.*</code> document ID that is not publicly readable.
|
|
55
|
+
</Text>
|
|
56
|
+
<Text size={1} muted>
|
|
57
|
+
Create a token at{' '}
|
|
58
|
+
<strong>vercel.com → Settings → Tokens</strong>.
|
|
59
|
+
Choose <strong>Full Account</strong> scope.
|
|
60
|
+
</Text>
|
|
61
|
+
</Stack>
|
|
62
|
+
|
|
63
|
+
<Stack space={3}>
|
|
64
|
+
<Text size={1} weight="semibold">Vercel API Token</Text>
|
|
65
|
+
<TextInput
|
|
66
|
+
value={token}
|
|
67
|
+
onChange={e => setToken((e.target as HTMLInputElement).value)}
|
|
68
|
+
placeholder="xxxxxxxxxxxxxxxxxxxxxxxx"
|
|
69
|
+
type="password"
|
|
70
|
+
/>
|
|
71
|
+
</Stack>
|
|
72
|
+
|
|
73
|
+
{error && (
|
|
74
|
+
<Card tone="critical" padding={3} radius={2}>
|
|
75
|
+
<Text size={1}>{error}</Text>
|
|
76
|
+
</Card>
|
|
77
|
+
)}
|
|
78
|
+
|
|
79
|
+
<Button
|
|
80
|
+
text="Save and connect"
|
|
81
|
+
tone="primary"
|
|
82
|
+
icon={CheckmarkCircleIcon}
|
|
83
|
+
loading={saving}
|
|
84
|
+
disabled={!token.trim() || saving}
|
|
85
|
+
onClick={save}
|
|
86
|
+
/>
|
|
87
|
+
</Stack>
|
|
88
|
+
</Card>
|
|
89
|
+
</Flex>
|
|
90
|
+
</Card>
|
|
91
|
+
)
|
|
92
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// deploy-vercel-from-sanity — Sanity Studio v5 plugin for Vercel deployments
|
|
2
|
+
import { definePlugin } from 'sanity'
|
|
3
|
+
import { RocketIcon } from '@sanity/icons'
|
|
4
|
+
import { DeployTool } from './components/DeployTool'
|
|
5
|
+
import { vercelDeploySchema } from './schema/vercelDeploy'
|
|
6
|
+
import type { VercelDeployPluginConfig } from './types'
|
|
7
|
+
|
|
8
|
+
export { vercelDeploySchema } from './schema/vercelDeploy'
|
|
9
|
+
export type { VercelDeployPluginConfig, DeployTarget, VercelDeployment, VercelDeployState } from './types'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Sanity Studio v5 plugin — trigger and monitor Vercel deployments.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* // sanity.config.ts
|
|
16
|
+
* import { vercelDeploy } from 'deploy-vercel-from-sanity'
|
|
17
|
+
*
|
|
18
|
+
* export default defineConfig({
|
|
19
|
+
* plugins: [
|
|
20
|
+
* vercelDeploy(),
|
|
21
|
+
* // or with options:
|
|
22
|
+
* vercelDeploy({ title: 'Deploy', name: 'vercel-deploy' }),
|
|
23
|
+
* ],
|
|
24
|
+
* })
|
|
25
|
+
*/
|
|
26
|
+
export const vercelDeploy = definePlugin<VercelDeployPluginConfig | void>(options => {
|
|
27
|
+
const config = options ?? {}
|
|
28
|
+
return {
|
|
29
|
+
name: 'deploy-vercel-from-sanity',
|
|
30
|
+
schema: {
|
|
31
|
+
types: [vercelDeploySchema],
|
|
32
|
+
},
|
|
33
|
+
tools: [
|
|
34
|
+
{
|
|
35
|
+
name: config.name ?? 'vercel-deploy',
|
|
36
|
+
title: config.title ?? 'Deploy',
|
|
37
|
+
icon: config.icon ?? RocketIcon,
|
|
38
|
+
component: DeployTool,
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
}
|
|
42
|
+
})
|
package/src/lib/api.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Vercel REST API helpers — all calls require a bearer token
|
|
2
|
+
import type { VercelDeployment } from '../types'
|
|
3
|
+
|
|
4
|
+
const BASE = 'https://api.vercel.com'
|
|
5
|
+
|
|
6
|
+
async function vercelFetch<T>(path: string, token: string, init?: RequestInit): Promise<T> {
|
|
7
|
+
const res = await fetch(`${BASE}${path}`, {
|
|
8
|
+
...init,
|
|
9
|
+
headers: {
|
|
10
|
+
Authorization: `Bearer ${token}`,
|
|
11
|
+
'Content-Type': 'application/json',
|
|
12
|
+
...init?.headers,
|
|
13
|
+
},
|
|
14
|
+
})
|
|
15
|
+
if (!res.ok) {
|
|
16
|
+
const text = await res.text().catch(() => res.statusText)
|
|
17
|
+
throw new Error(`Vercel API ${res.status}: ${text}`)
|
|
18
|
+
}
|
|
19
|
+
return res.json() as Promise<T>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Fetch the last N deployments triggered by a specific deploy hook */
|
|
23
|
+
export async function listDeployments(opts: {
|
|
24
|
+
projectId: string
|
|
25
|
+
hookId: string
|
|
26
|
+
token: string
|
|
27
|
+
teamId?: string
|
|
28
|
+
limit?: number
|
|
29
|
+
}): Promise<VercelDeployment[]> {
|
|
30
|
+
const params = new URLSearchParams({
|
|
31
|
+
projectId: opts.projectId,
|
|
32
|
+
'meta-deployHookId': opts.hookId,
|
|
33
|
+
limit: String(opts.limit ?? 10),
|
|
34
|
+
})
|
|
35
|
+
if (opts.teamId) params.set('teamId', opts.teamId)
|
|
36
|
+
const data = await vercelFetch<{ deployments: VercelDeployment[] }>(
|
|
37
|
+
`/v6/deployments?${params}`,
|
|
38
|
+
opts.token,
|
|
39
|
+
)
|
|
40
|
+
return data.deployments ?? []
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Cancel an in-progress deployment */
|
|
44
|
+
export async function cancelDeployment(opts: {
|
|
45
|
+
deploymentId: string
|
|
46
|
+
token: string
|
|
47
|
+
teamId?: string
|
|
48
|
+
}): Promise<void> {
|
|
49
|
+
const params = opts.teamId ? `?teamId=${opts.teamId}` : ''
|
|
50
|
+
await vercelFetch(`/v12/deployments/${opts.deploymentId}/cancel${params}`, opts.token, {
|
|
51
|
+
method: 'PATCH',
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Trigger a deploy by POSTing to the hook URL — no auth needed */
|
|
56
|
+
export async function triggerDeploy(hookUrl: string): Promise<void> {
|
|
57
|
+
const res = await fetch(hookUrl, { method: 'POST' })
|
|
58
|
+
if (!res.ok) throw new Error(`Deploy hook returned ${res.status}`)
|
|
59
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// URL parsing and time formatting utilities
|
|
2
|
+
import type { VercelDeployState } from '../types'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Extracts projectId and hookId from a Vercel deploy hook URL.
|
|
6
|
+
* Format: https://api.vercel.com/v1/integrations/deploy/{projectId}/{hookId}
|
|
7
|
+
*/
|
|
8
|
+
export function parseHookUrl(url: string): { projectId: string; hookId: string } {
|
|
9
|
+
try {
|
|
10
|
+
const path = new URL(url).pathname
|
|
11
|
+
const parts = path.split('/').filter(Boolean)
|
|
12
|
+
// parts: ['v1', 'integrations', 'deploy', '{projectId}', '{hookId}']
|
|
13
|
+
return {
|
|
14
|
+
projectId: parts[3] ?? '',
|
|
15
|
+
hookId: parts[4] ?? '',
|
|
16
|
+
}
|
|
17
|
+
} catch {
|
|
18
|
+
return { projectId: '', hookId: '' }
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Active states — deployment is in progress and should be polled */
|
|
23
|
+
const ACTIVE_STATES: ReadonlySet<VercelDeployState> = new Set([
|
|
24
|
+
'QUEUED',
|
|
25
|
+
'INITIALIZING',
|
|
26
|
+
'BUILDING',
|
|
27
|
+
])
|
|
28
|
+
|
|
29
|
+
export function isActiveState(state: VercelDeployState | undefined): boolean {
|
|
30
|
+
return !!state && ACTIVE_STATES.has(state)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Human-readable elapsed duration from a number of seconds */
|
|
34
|
+
export function formatDuration(seconds: number): string {
|
|
35
|
+
if (seconds < 60) return `${seconds}s`
|
|
36
|
+
const m = Math.floor(seconds / 60)
|
|
37
|
+
const s = seconds % 60
|
|
38
|
+
return `${m}m ${s}s`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Human-readable relative time from a Unix ms timestamp */
|
|
42
|
+
export function timeAgo(ms: number): string {
|
|
43
|
+
const diff = Math.floor((Date.now() - ms) / 1000)
|
|
44
|
+
if (diff < 60) return `${diff}s ago`
|
|
45
|
+
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
|
|
46
|
+
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
|
|
47
|
+
return `${Math.floor(diff / 86400)}d ago`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Validates a URL is safe to use as an href.
|
|
52
|
+
* Rejects anything that isn't http/https to block javascript: injection
|
|
53
|
+
* from a compromised API response.
|
|
54
|
+
*/
|
|
55
|
+
export function safeHref(url: string | undefined): string | undefined {
|
|
56
|
+
if (!url) return undefined
|
|
57
|
+
try {
|
|
58
|
+
const parsed = new URL(url)
|
|
59
|
+
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return undefined
|
|
60
|
+
return url
|
|
61
|
+
} catch {
|
|
62
|
+
return undefined
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Truncates a commit SHA to 7 chars */
|
|
67
|
+
export function shortSha(sha: string | undefined): string {
|
|
68
|
+
return sha ? sha.slice(0, 7) : ''
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Returns a label and tone for a Vercel deployment state */
|
|
72
|
+
export function stateLabel(state: VercelDeployState | undefined): {
|
|
73
|
+
label: string
|
|
74
|
+
tone: 'positive' | 'caution' | 'critical' | 'default'
|
|
75
|
+
} {
|
|
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' }
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Sanity schema for vercel_deploy documents — stores deploy hook targets
|
|
2
|
+
import { defineField, defineType } from 'sanity'
|
|
3
|
+
import { RocketIcon } from '@sanity/icons'
|
|
4
|
+
|
|
5
|
+
export const vercelDeploySchema = defineType({
|
|
6
|
+
name: 'vercel_deploy',
|
|
7
|
+
title: 'Deploy Target',
|
|
8
|
+
type: 'document',
|
|
9
|
+
icon: RocketIcon,
|
|
10
|
+
fields: [
|
|
11
|
+
defineField({
|
|
12
|
+
name: 'name',
|
|
13
|
+
title: 'Name',
|
|
14
|
+
type: 'string',
|
|
15
|
+
description: 'Display label shown in the Deploy tool (e.g. "Production", "Staging")',
|
|
16
|
+
validation: Rule => Rule.required(),
|
|
17
|
+
}),
|
|
18
|
+
defineField({
|
|
19
|
+
name: 'url',
|
|
20
|
+
title: 'Deploy Hook URL',
|
|
21
|
+
type: 'url',
|
|
22
|
+
description: 'From Vercel → Project Settings → Git → Deploy Hooks',
|
|
23
|
+
validation: Rule =>
|
|
24
|
+
Rule.required().uri({ scheme: ['https'] }).custom(url => {
|
|
25
|
+
if (typeof url !== 'string') return true
|
|
26
|
+
if (!url.includes('api.vercel.com/v1/integrations/deploy/')) {
|
|
27
|
+
return 'Must be a Vercel deploy hook URL (api.vercel.com/v1/integrations/deploy/…)'
|
|
28
|
+
}
|
|
29
|
+
return true
|
|
30
|
+
}),
|
|
31
|
+
}),
|
|
32
|
+
defineField({
|
|
33
|
+
name: 'teamId',
|
|
34
|
+
title: 'Vercel Team ID',
|
|
35
|
+
type: 'string',
|
|
36
|
+
description: 'Required for team-owned projects — find it in Vercel Team Settings',
|
|
37
|
+
}),
|
|
38
|
+
defineField({
|
|
39
|
+
name: 'disableDeleteAction',
|
|
40
|
+
title: 'Prevent deletion',
|
|
41
|
+
type: 'boolean',
|
|
42
|
+
description: 'Lock this target so it cannot be deleted from the Studio',
|
|
43
|
+
initialValue: false,
|
|
44
|
+
}),
|
|
45
|
+
],
|
|
46
|
+
preview: {
|
|
47
|
+
select: { title: 'name', subtitle: 'url' },
|
|
48
|
+
},
|
|
49
|
+
})
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// TypeScript types for deploy-vercel-from-sanity
|
|
2
|
+
|
|
3
|
+
export type VercelDeployState =
|
|
4
|
+
| 'QUEUED'
|
|
5
|
+
| 'INITIALIZING'
|
|
6
|
+
| 'BUILDING'
|
|
7
|
+
| 'READY'
|
|
8
|
+
| 'ERROR'
|
|
9
|
+
| 'CANCELED'
|
|
10
|
+
| 'LOADING' // internal — before first API response
|
|
11
|
+
|
|
12
|
+
/** A vercel_deploy document stored in the Sanity dataset */
|
|
13
|
+
export interface DeployTarget {
|
|
14
|
+
_id: string
|
|
15
|
+
_type: 'vercel_deploy'
|
|
16
|
+
name: string
|
|
17
|
+
/** Full Vercel deploy hook URL */
|
|
18
|
+
url: string
|
|
19
|
+
/** Vercel team ID — optional, only needed for team projects */
|
|
20
|
+
teamId?: string
|
|
21
|
+
/** Prevent editors from deleting this target */
|
|
22
|
+
disableDeleteAction?: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A single deployment returned by GET /v6/deployments */
|
|
26
|
+
export interface VercelDeployment {
|
|
27
|
+
uid: string
|
|
28
|
+
/** Preview hostname, e.g. my-project-abc123.vercel.app */
|
|
29
|
+
url: string
|
|
30
|
+
state: VercelDeployState
|
|
31
|
+
/** Unix ms timestamp */
|
|
32
|
+
created: number
|
|
33
|
+
/** Link to the Vercel dashboard page for this deployment */
|
|
34
|
+
inspectorUrl?: string
|
|
35
|
+
creator?: {
|
|
36
|
+
uid: string
|
|
37
|
+
username: string
|
|
38
|
+
avatar?: string
|
|
39
|
+
}
|
|
40
|
+
meta?: {
|
|
41
|
+
githubCommitMessage?: string
|
|
42
|
+
githubCommitRef?: string
|
|
43
|
+
githubCommitSha?: string
|
|
44
|
+
githubCommitAuthorName?: string
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Vercel secrets document stored at _id: 'secrets.vercelDeploy' */
|
|
49
|
+
export interface VercelSecrets {
|
|
50
|
+
_id: 'secrets.vercelDeploy'
|
|
51
|
+
_type: 'vercelDeploy.config'
|
|
52
|
+
accessToken: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Plugin configuration options */
|
|
56
|
+
export interface VercelDeployPluginConfig {
|
|
57
|
+
/** Tool name slug shown in Studio sidebar (default: 'vercel-deploy') */
|
|
58
|
+
name?: string
|
|
59
|
+
/** Tool label shown in Studio sidebar (default: 'Deploy') */
|
|
60
|
+
title?: string
|
|
61
|
+
/** Custom icon component */
|
|
62
|
+
icon?: React.ComponentType
|
|
63
|
+
}
|