Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
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