Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/pr-1580.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<!-- auto-generated -->
---
'@sanity/cli': minor
---

feat(cli): remind about unclaimed projects before they expire
1 change: 1 addition & 0 deletions packages/@sanity/cli/oclif.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export default {
'./dist/hooks/prerun/injectEnvVariables.js',
'./dist/hooks/prerun/setupTelemetry.js',
'./dist/hooks/prerun/warnings.js',
'./dist/hooks/prerun/claimReminders.js',
Comment thread
shapirodaniel marked this conversation as resolved.
],
},
// Note: do not add '@sanity/migrate' here. The `migrations` commands now ship
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {NewCommand} from '../../new.js'
import {MintProjectCommand} from '../mint.js'

const mockMintUnclaimedProject = vi.hoisted(() => vi.fn())
const mockRecordMintedProject = vi.hoisted(() => vi.fn())
const mockAppendEnvValues = vi.hoisted(() => vi.fn())
const mockInput = vi.hoisted(() => vi.fn())

Expand All @@ -26,6 +27,9 @@ vi.mock('@sanity/cli-core/ux', async (importOriginal) => {
vi.mock('../../../services/mintProject.js', () => ({
mintUnclaimedProject: mockMintUnclaimedProject,
}))
vi.mock('../../../util/claimNudges.js', () => ({
recordMintedProject: mockRecordMintedProject,
}))
vi.mock('../../../util/envFile.js', () => ({
appendEnvValues: mockAppendEnvValues,
}))
Expand Down Expand Up @@ -76,6 +80,7 @@ describe('#projects:mint', () => {
await MintProjectCommand.run(['My New Project'])

expect(mockMintUnclaimedProject).toHaveBeenCalledWith({displayName: 'My New Project'})
expect(mockRecordMintedProject).toHaveBeenCalledWith(mockMinted)
expect(mocks.SanityCmdOutput.error).not.toHaveBeenCalled()

const lines = loggedLines()
Expand Down
4 changes: 4 additions & 0 deletions packages/@sanity/cli/src/commands/projects/mint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {SanityCommand} from '@sanity/cli-core/SanityCommand'
import {input} from '@sanity/cli-core/ux'

import {mintUnclaimedProject} from '../../services/mintProject.js'
import {recordMintedProject} from '../../util/claimNudges.js'
import {appendEnvValues} from '../../util/envFile.js'
import {createFlow} from '../../util/flowOutput.js'
import {renderNewCommandSplash} from '../../util/newCommandSplash.js'
Expand Down Expand Up @@ -139,6 +140,9 @@ export class MintProjectCommand extends SanityCommand<typeof MintProjectCommand>
}
spin?.succeed('Project minted!')

// Remember the mint so later CLI invocations can nudge toward claiming before expiry.
recordMintedProject(minted)

flow.gap()
flow.result(`Project ID: ${styleText('cyan', minted.resourceId)}`)
flow.result(`Dataset: ${styleText('cyan', minted.datasetName)}`)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import {afterEach, describe, expect, test, vi} from 'vitest'

import {claimReminders} from '../claimReminders.js'

const mockRunClaimNudges = vi.hoisted(() => vi.fn())

vi.mock('../../../util/claimNudges.js', () => ({
runClaimNudges: mockRunClaimNudges,
}))

function runHook(commandId: string | undefined) {
return (claimReminders as (opts: unknown) => Promise<void>).call(
{},
{Command: commandId ? {id: commandId} : undefined},
)
}

afterEach(() => {
vi.clearAllMocks()
})

describe('#claimReminders', () => {
test('runs the nudge check for regular commands', async () => {
await runHook('versions')

expect(mockRunClaimNudges).toHaveBeenCalledTimes(1)
})

test.each(['new', 'projects:mint', 'project:mint'])('skips the %s command', async (id) => {
await runHook(id)

expect(mockRunClaimNudges).not.toHaveBeenCalled()
})

test('never throws when the nudge check fails', async () => {
mockRunClaimNudges.mockRejectedValue(new Error('config unreadable'))

await expect(runHook('versions')).resolves.toBeUndefined()
})
})
27 changes: 27 additions & 0 deletions packages/@sanity/cli/src/hooks/prerun/claimReminders.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import {type Hook} from '@oclif/core'
import {subdebug} from '@sanity/cli-core/debug'

import {runClaimNudges} from '../../util/claimNudges.js'

const debug = subdebug('claimNudges')

/**
* Commands that already put claim details front and center — nudging during them is noise.
*/
const SKIP_COMMANDS = new Set(['new', 'project:mint', 'projects:mint'])

/**
* Prerun hook that reminds the user (or their agent) about minted-but-unclaimed projects
* approaching the end of their claim window. Writes to stderr so it never corrupts
* machine-readable stdout (e.g. `--json`), and silently fails — a reminder must never break the
* command being run.
*/
export const claimReminders: Hook.Prerun = async function (opts) {
if (SKIP_COMMANDS.has(opts.Command?.id ?? '')) return

try {
await runClaimNudges((line) => process.stderr.write(`${line}\n`))
} catch (err) {
debug('claim reminder check failed: %s', err)
}
}
34 changes: 34 additions & 0 deletions packages/@sanity/cli/src/services/mintProject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,40 @@ function getProvisionApiBase(): string {
return isStaging() ? 'https://api.sanity.work' : 'https://api.sanity.io'
}

/** Claim state of a minted resource, per the provision lookup endpoint. */
export type ClaimState = 'claimable' | 'claimed' | 'expired'

/**
* Look up the claim state for a minted project. Unauthenticated and non-destructive — possession
* of the claim token is the capability, and the endpoint is safe to poll.
*
* Returns `undefined` when the state can't be determined (network failure, timeout, kill switch
* off) so callers can fail open and fall back to locally stored expiry data.
*/
export async function lookupClaimState(
claimToken: string,
options?: {timeoutMs?: number},
): Promise<{expiresAt: string | null; state: ClaimState} | undefined> {
const url = `${getProvisionApiBase()}/${PROVISION_API_VERSION}/provision/${claimToken}/lookup`
debug('looking up claim state at %s', url)

try {
const response = await fetch(url, {
signal: AbortSignal.timeout(options?.timeoutMs ?? 1500),
})
if (!response.ok) return undefined

const data = (await response.json()) as {expiresAt?: string | null; state?: ClaimState}
if (data.state !== 'claimable' && data.state !== 'claimed' && data.state !== 'expired') {
return undefined
}
return {expiresAt: data.expiresAt ?? null, state: data.state}
} catch (err) {
debug('claim state lookup failed: %s', err)
return undefined
}
}

/**
* Mint an unclaimed Sanity project via the unauthenticated provision endpoint, returning a
* scoped robot token and a claim URL:
Expand Down
Loading
Loading