@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.
@@ -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
+ }