From 05a316ac9d430ef5a695eb3ae80938aea1fb813b Mon Sep 17 00:00:00 2001 From: Daniel Shapiro Date: Tue, 21 Jul 2026 22:02:14 -0400 Subject: [PATCH 1/2] feat(cli): remind about unclaimed projects before they expire Third layer of the mint-and-claim stack: a machine-global mint ledger in CLI user config, escalating stderr nudges (48/24/8/2h) via a prerun hook, claim-state lookup on the provision API, and the mint command recording each mint into the ledger. Co-Authored-By: Claude Fable 5 --- packages/@sanity/cli/oclif.config.js | 1 + .../commands/projects/__tests__/mint.test.ts | 5 + .../@sanity/cli/src/commands/projects/mint.ts | 4 + .../prerun/__tests__/claimReminders.test.ts | 40 ++ .../cli/src/hooks/prerun/claimReminders.ts | 27 ++ .../@sanity/cli/src/services/mintProject.ts | 34 ++ .../src/util/__tests__/claimNudges.test.ts | 350 ++++++++++++++++++ packages/@sanity/cli/src/util/claimNudges.ts | 262 +++++++++++++ 8 files changed, 723 insertions(+) create mode 100644 packages/@sanity/cli/src/hooks/prerun/__tests__/claimReminders.test.ts create mode 100644 packages/@sanity/cli/src/hooks/prerun/claimReminders.ts create mode 100644 packages/@sanity/cli/src/util/__tests__/claimNudges.test.ts create mode 100644 packages/@sanity/cli/src/util/claimNudges.ts diff --git a/packages/@sanity/cli/oclif.config.js b/packages/@sanity/cli/oclif.config.js index b809e5119f..7fd6f11e23 100644 --- a/packages/@sanity/cli/oclif.config.js +++ b/packages/@sanity/cli/oclif.config.js @@ -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', ], }, // Note: do not add '@sanity/migrate' here. The `migrations` commands now ship diff --git a/packages/@sanity/cli/src/commands/projects/__tests__/mint.test.ts b/packages/@sanity/cli/src/commands/projects/__tests__/mint.test.ts index d73f9de478..6427e49e3f 100644 --- a/packages/@sanity/cli/src/commands/projects/__tests__/mint.test.ts +++ b/packages/@sanity/cli/src/commands/projects/__tests__/mint.test.ts @@ -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()) @@ -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, })) @@ -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() diff --git a/packages/@sanity/cli/src/commands/projects/mint.ts b/packages/@sanity/cli/src/commands/projects/mint.ts index fe0bf0f657..6f9237c387 100644 --- a/packages/@sanity/cli/src/commands/projects/mint.ts +++ b/packages/@sanity/cli/src/commands/projects/mint.ts @@ -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' @@ -139,6 +140,9 @@ export class MintProjectCommand extends SanityCommand } 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)}`) diff --git a/packages/@sanity/cli/src/hooks/prerun/__tests__/claimReminders.test.ts b/packages/@sanity/cli/src/hooks/prerun/__tests__/claimReminders.test.ts new file mode 100644 index 0000000000..9c17e29c22 --- /dev/null +++ b/packages/@sanity/cli/src/hooks/prerun/__tests__/claimReminders.test.ts @@ -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).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() + }) +}) diff --git a/packages/@sanity/cli/src/hooks/prerun/claimReminders.ts b/packages/@sanity/cli/src/hooks/prerun/claimReminders.ts new file mode 100644 index 0000000000..e75db7439b --- /dev/null +++ b/packages/@sanity/cli/src/hooks/prerun/claimReminders.ts @@ -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) + } +} diff --git a/packages/@sanity/cli/src/services/mintProject.ts b/packages/@sanity/cli/src/services/mintProject.ts index 9d8d12a169..2d7ad38562 100644 --- a/packages/@sanity/cli/src/services/mintProject.ts +++ b/packages/@sanity/cli/src/services/mintProject.ts @@ -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: diff --git a/packages/@sanity/cli/src/util/__tests__/claimNudges.test.ts b/packages/@sanity/cli/src/util/__tests__/claimNudges.test.ts new file mode 100644 index 0000000000..f79db1ddc7 --- /dev/null +++ b/packages/@sanity/cli/src/util/__tests__/claimNudges.test.ts @@ -0,0 +1,350 @@ +import {getUserConfig} from '@sanity/cli-core' +import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' + +import {lookupClaimState} from '../../services/mintProject.js' +import { + recordMintedProject, + runClaimNudges, + UNCLAIMED_PROJECTS_CONFIG_KEY, + type UnclaimedProjectRecord, +} from '../claimNudges.js' + +vi.mock(import('@sanity/cli-core'), async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getUserConfig: vi.fn(), + } +}) +vi.mock('../../services/mintProject.js', () => ({ + lookupClaimState: vi.fn(), +})) + +const mockGetUserConfig = vi.mocked(getUserConfig) +const mockLookupClaimState = vi.mocked(lookupClaimState) + +const HOUR = 3_600_000 +const NOW = new Date('2026-07-15T12:00:00.000Z').getTime() + +let store: Record = {} + +function seedRecord(overrides: Partial = {}): UnclaimedProjectRecord { + const record: UnclaimedProjectRecord = { + claimToken: 'claim-token', + claimUrl: 'https://www.sanity.io/claim/some-token', + expiresAt: new Date(NOW + 47 * HOUR).toISOString(), + mintedAt: new Date(NOW - HOUR).toISOString(), + projectId: 'abc123', + ...overrides, + } + const records = (store[UNCLAIMED_PROJECTS_CONFIG_KEY] ?? {}) as Record + store[UNCLAIMED_PROJECTS_CONFIG_KEY] = {...records, [record.projectId]: record} + return record +} + +function storedRecords(): Record { + return (store[UNCLAIMED_PROJECTS_CONFIG_KEY] ?? {}) as Record +} + +async function run(now = NOW): Promise { + const write = vi.fn() + await runClaimNudges(write, now) + return write.mock.calls.map(([line]) => String(line)).join('\n') +} + +beforeEach(() => { + store = {} + mockGetUserConfig.mockReturnValue({ + delete: (key: string) => { + delete store[key] + }, + get: (key: string) => store[key], + set: (key: string, value: unknown) => { + store[key] = value + }, + }) + mockLookupClaimState.mockResolvedValue({ + expiresAt: new Date(NOW + 47 * HOUR).toISOString(), + state: 'claimable', + }) +}) + +afterEach(() => { + vi.clearAllMocks() +}) + +describe('#recordMintedProject', () => { + test('persists the minted project to the registry', () => { + recordMintedProject({ + apiHost: 'https://abc123.api.sanity.io', + claimApiUrl: 'https://api.sanity.io/v1/provision/claim', + claimToken: 'claim-token', + claimUrl: 'https://www.sanity.io/claim/some-token', + datasetName: 'production', + expiresAt: '2026-07-18T12:00:00.000Z', + resourceId: 'abc123', + token: 'sk-robot', + }) + + expect(storedRecords().abc123).toMatchObject({ + claimToken: 'claim-token', + claimUrl: 'https://www.sanity.io/claim/some-token', + expiresAt: '2026-07-18T12:00:00.000Z', + projectId: 'abc123', + }) + }) + + test('swallows config write failures', () => { + mockGetUserConfig.mockImplementation(() => { + throw new Error('disk full') + }) + + expect(() => + recordMintedProject({ + apiHost: 'x', + claimApiUrl: 'x', + claimToken: 'x', + claimUrl: 'x', + datasetName: 'x', + expiresAt: 'x', + resourceId: 'x', + token: 'x', + }), + ).not.toThrow() + }) +}) + +describe('#runClaimNudges', () => { + test('a malformed registry entry never silences reminders for healthy projects', async () => { + // The registry is user-editable state (UAT helpers, hand edits). A half-broken entry — + // e.g. an expiresAt with no claimUrl/claimToken — must be invisible, not crash the pass: + // the never-throw hook would swallow the crash and silence every reminder. + const records = (store[UNCLAIMED_PROJECTS_CONFIG_KEY] ?? {}) as Record + const mismatched = seedRecord({projectId: 'zzz999'}) // full record, then re-keyed wrong: + store[UNCLAIMED_PROJECTS_CONFIG_KEY] = { + ...records, + phantom: {expiresAt: new Date(NOW + 2 * HOUR).toISOString()}, + wrongkey: {...mismatched}, + } + delete (store[UNCLAIMED_PROJECTS_CONFIG_KEY] as Record).zzz999 + seedRecord({expiresAt: new Date(NOW + 47 * HOUR).toISOString()}) + + const output = await run() + + expect(output).toContain('⏳ Claim your Sanity project — abc123') + expect(output).not.toContain('phantom') + expect(output).not.toContain('zzz999') + expect(storedRecords().abc123.lastNudgeTier).toBe(1) + // The write that recorded the healthy nudge persists the filtered view — the malformed and + // mis-keyed entries are dropped rather than resurrected (self-healing; consumers key every + // mutation by projectId, so a mismatched entry could only ever repeat farewells). + expect(storedRecords().phantom).toBeUndefined() + expect(storedRecords().wrongkey).toBeUndefined() + }) + + test('a mint landing during the lookup window survives the registry write', async () => { + // The pass snapshots the registry, then awaits claim-state lookups for up to seconds. A + // record written by another process in that window (most damagingly `sanity new` finishing + // a mint) must not be clobbered when the pass persists its own outcome. + seedRecord({expiresAt: new Date(NOW + 47 * HOUR).toISOString()}) + mockLookupClaimState.mockImplementation(async () => { + const records = (store[UNCLAIMED_PROJECTS_CONFIG_KEY] ?? {}) as Record + store[UNCLAIMED_PROJECTS_CONFIG_KEY] = { + ...records, + fresh99: { + claimToken: 'fresh-token', + claimUrl: 'https://www.sanity.io/claim/fresh-token', + expiresAt: new Date(NOW + 71 * HOUR).toISOString(), + mintedAt: new Date(NOW).toISOString(), + projectId: 'fresh99', + }, + } + return {expiresAt: new Date(NOW + 47 * HOUR).toISOString(), state: 'claimable'} + }) + + const output = await run() + + expect(output).toContain('⏳ Claim your Sanity project — abc123') + // The pass's own outcome persisted… + expect(storedRecords().abc123.lastNudgeTier).toBe(1) + // …and the concurrent mint's record survived the write. + expect(storedRecords().fresh99).toBeDefined() + }) + + test('does nothing when the registry is empty', async () => { + expect(await run()).toBe('') + expect(mockLookupClaimState).not.toHaveBeenCalled() + }) + + test('stays quiet with more than 48 hours remaining', async () => { + seedRecord({expiresAt: new Date(NOW + 60 * HOUR).toISOString()}) + + expect(await run()).toBe('') + expect(mockLookupClaimState).not.toHaveBeenCalled() + }) + + test('tier 1 fires under 48 hours and is shown only once', async () => { + seedRecord({expiresAt: new Date(NOW + 47 * HOUR).toISOString()}) + + const first = await run() + expect(first).toContain('⏳ Claim your Sanity project') + expect(first).toContain('abc123') + expect(first).toContain('expires in about 47 hours') + expect(first).toContain('https://www.sanity.io/claim/some-token') + expect(first).not.toContain('╭') // compact lines, never a box + expect(storedRecords().abc123.lastNudgeTier).toBe(1) + + expect(await run()).toBe('') + expect(mockLookupClaimState).toHaveBeenCalledTimes(1) + }) + + test('escalates through tiers as the deadline approaches', async () => { + seedRecord({expiresAt: new Date(NOW + 47 * HOUR).toISOString(), lastNudgeTier: 1}) + + const tier2 = await run(NOW + 24 * HOUR) + expect(tier2).toContain('⏰ Project abc123 expires in about 23 hours') + expect(storedRecords().abc123.lastNudgeTier).toBe(2) + + const tier3 = await run(NOW + 40 * HOUR) + expect(tier3).toContain('🚨 Project abc123 expires in about 7 hours') + expect(storedRecords().abc123.lastNudgeTier).toBe(3) + + const tier4 = await run(NOW + 45.5 * HOUR) + expect(tier4).toContain('🚨 Final reminder') + expect(tier4).toContain('about 90 minutes') + expect(storedRecords().abc123.lastNudgeTier).toBe(4) + + expect(await run(NOW + 46 * HOUR)).toBe('') + }) + + test('tier nudges include the agent call-to-action', async () => { + seedRecord({expiresAt: new Date(NOW + 23 * HOUR).toISOString()}) + + expect(await run()).toContain('AI agents: surface this claim link to your human now') + }) + + test('confirms and forgets a claimed project instead of nudging', async () => { + mockLookupClaimState.mockResolvedValue({expiresAt: null, state: 'claimed'}) + seedRecord({expiresAt: new Date(NOW + 23 * HOUR).toISOString()}) + + const output = await run() + + expect(output).toContain('has been claimed') + expect(storedRecords().abc123).toBeUndefined() + }) + + test('notes and forgets a remotely expired project', async () => { + mockLookupClaimState.mockResolvedValue({expiresAt: null, state: 'expired'}) + seedRecord({expiresAt: new Date(NOW + 23 * HOUR).toISOString()}) + + const output = await run() + + expect(output).toContain('⌛ Unclaimed Sanity project abc123 has expired') + expect(output).toContain('Run `sanity new` to mint a new one') + expect(output).not.toContain('reclaimed') + expect(storedRecords().abc123).toBeUndefined() + }) + + test('fails open when the lookup is unavailable', async () => { + mockLookupClaimState.mockResolvedValue(undefined) + seedRecord({expiresAt: new Date(NOW + 23 * HOUR).toISOString()}) + + expect(await run()).toContain('⏰ Project abc123 expires in about 23 hours') + }) + + test('notifies once about locally expired projects and forgets them (lookup fails open)', async () => { + mockLookupClaimState.mockResolvedValue(undefined) + seedRecord({expiresAt: new Date(NOW - HOUR).toISOString()}) + + const output = await run() + + expect(output).toContain('⌛ Unclaimed Sanity project abc123 expired on') + expect(output).toContain('Run `sanity new` to mint a new one') + expect(output).not.toContain('reclaimed') + expect(storedRecords().abc123).toBeUndefined() + // The farewell is verified first — never announced from the local clock alone. + expect(mockLookupClaimState).toHaveBeenCalledWith('claim-token') + + expect(await run()).toBe('') + }) + + test('locally expired but actually claimed: congratulates instead of the expiry notice', async () => { + mockLookupClaimState.mockResolvedValue({expiresAt: null, state: 'claimed'}) + seedRecord({expiresAt: new Date(NOW - HOUR).toISOString()}) + + const output = await run() + + expect(output).toContain('has been claimed') + expect(output).not.toContain('expired on') + expect(storedRecords().abc123).toBeUndefined() + }) + + test('locally expired but server says claimable: refreshes the expiry, keeps the record', async () => { + const serverExpiry = new Date(NOW + 12 * HOUR).toISOString() + mockLookupClaimState.mockResolvedValue({expiresAt: serverExpiry, state: 'claimable'}) + seedRecord({expiresAt: new Date(NOW - HOUR).toISOString()}) + + const output = await run() + + expect(output).not.toContain('expired on') + expect(storedRecords().abc123.expiresAt).toBe(serverExpiry) + }) + + test('renders the nudge from the server expiry when the window changed', async () => { + // Local copy says 47h (tier 1 due); server says 23h — the countdown must not lie. + seedRecord({expiresAt: new Date(NOW + 47 * HOUR).toISOString()}) + mockLookupClaimState.mockResolvedValue({ + expiresAt: new Date(NOW + 23 * HOUR).toISOString(), + state: 'claimable', + }) + + const output = await run() + + expect(output).toContain('expires in about 23 hours') + expect(storedRecords().abc123.expiresAt).toBe(new Date(NOW + 23 * HOUR).toISOString()) + expect(storedRecords().abc123.lastNudgeTier).toBe(2) + }) + + test('skips the nudge when the server-extended window means none is due', async () => { + seedRecord({expiresAt: new Date(NOW + 47 * HOUR).toISOString()}) + mockLookupClaimState.mockResolvedValue({ + expiresAt: new Date(NOW + 60 * HOUR).toISOString(), + state: 'claimable', + }) + + const output = await run() + + expect(output).toBe('') + expect(storedRecords().abc123.expiresAt).toBe(new Date(NOW + 60 * HOUR).toISOString()) + expect(storedRecords().abc123.lastNudgeTier).toBeUndefined() + }) + + test('a lapsed-but-claimable record never renders a bogus final reminder', async () => { + // Farewell keeps the record (server says claimable, no fresh expiry) — the due path must + // not pick up its non-positive countdown and bell out "expires in about 1 minutes". + mockLookupClaimState.mockResolvedValue({expiresAt: null, state: 'claimable'}) + seedRecord({expiresAt: new Date(NOW - HOUR).toISOString()}) + + const output = await run() + + expect(output).toBe('') + expect(storedRecords().abc123).toBeDefined() + }) + + test('nudges only the most urgent project per invocation', async () => { + // Server agrees with the local expiry, so the tier comes out of the 5h countdown. + mockLookupClaimState.mockResolvedValue({ + expiresAt: new Date(NOW + 5 * HOUR).toISOString(), + state: 'claimable', + }) + seedRecord({expiresAt: new Date(NOW + 40 * HOUR).toISOString(), projectId: 'later00'}) + seedRecord({expiresAt: new Date(NOW + 5 * HOUR).toISOString(), projectId: 'sooner0'}) + + const output = await run() + + expect(output).toContain('sooner0') + expect(output).not.toContain('later00') + expect(storedRecords().sooner0.lastNudgeTier).toBe(3) + expect(storedRecords().later00.lastNudgeTier).toBeUndefined() + }) +}) diff --git a/packages/@sanity/cli/src/util/claimNudges.ts b/packages/@sanity/cli/src/util/claimNudges.ts new file mode 100644 index 0000000000..04e193a48f --- /dev/null +++ b/packages/@sanity/cli/src/util/claimNudges.ts @@ -0,0 +1,262 @@ +import {styleText} from 'node:util' + +import {getUserConfig} from '@sanity/cli-core' +import {subdebug} from '@sanity/cli-core/debug' +import {logSymbols} from '@sanity/cli-core/ux' + +import {lookupClaimState, type MintedProject} from '../services/mintProject.js' + +const debug = subdebug('claimNudges') + +/** User config key holding minted-but-unclaimed projects, keyed by project id. */ +export const UNCLAIMED_PROJECTS_CONFIG_KEY = 'unclaimedProjects' + +export interface UnclaimedProjectRecord { + claimToken: string + claimUrl: string + expiresAt: string + mintedAt: string + projectId: string + + /** Highest nudge tier already shown for this project — each tier fires at most once. */ + lastNudgeTier?: number +} + +/** + * The nudge waterfall: each tier activates when remaining time drops below its threshold, fires + * at most once per project, and escalates in visual weight. At most one nudge is shown per CLI + * invocation, so a project produces a maximum of four reminders across its 72-hour window. + */ +const NUDGE_TIERS = [ + {ms: 48 * 3_600_000, tier: 1}, + {ms: 24 * 3_600_000, tier: 2}, + {ms: 8 * 3_600_000, tier: 3}, + {ms: 2 * 3_600_000, tier: 4}, +] as const + +function tierFor(msLeft: number): number { + let current = 0 + for (const {ms, tier} of NUDGE_TIERS) { + if (msLeft <= ms) current = tier + } + return current +} + +function humanizeMsLeft(msLeft: number): string { + const minutes = Math.max(Math.round(msLeft / 60_000), 1) + if (minutes < 120) return `about ${minutes} minutes` + const hours = Math.round(minutes / 60) + return `about ${hours} hours` +} + +function isWellFormed(record: unknown): record is UnclaimedProjectRecord { + const candidate = record as Partial | null + return ( + typeof candidate?.claimToken === 'string' && + typeof candidate?.claimUrl === 'string' && + typeof candidate?.expiresAt === 'string' && + typeof candidate?.projectId === 'string' + ) +} + +function readRecords(): Record { + const raw = getUserConfig().get(UNCLAIMED_PROJECTS_CONFIG_KEY) + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {} + // The registry is user-editable state: a hand-edited or corrupted entry must never take down + // the whole nudge pass (the renderer's crash would be swallowed by the never-throw hook, + // silencing every reminder). This is the single validation boundary — consumers key every + // mutation by `record.projectId`, so an entry whose map key disagrees with its record is as + // unusable as one missing a field (deletes would no-op, farewells would repeat). Entries that + // fail either check are invisible to all consumers; they stay in the config untouched unless + // a reminder outcome triggers a rewrite. + return Object.fromEntries( + Object.entries(raw as Record).filter( + ([key, record]) => isWellFormed(record) && record.projectId === key, + ), + ) as Record +} + +function writeRecords(records: Record): void { + const config = getUserConfig() + if (Object.keys(records).length === 0) { + config.delete(UNCLAIMED_PROJECTS_CONFIG_KEY) + } else { + config.set(UNCLAIMED_PROJECTS_CONFIG_KEY, records) + } +} + +/** + * Persist a freshly minted project so later CLI invocations can nudge toward claiming it. + * Failures are swallowed — a config write must never fail the mint itself. + */ +export function recordMintedProject(minted: MintedProject): void { + try { + const records = readRecords() + records[minted.resourceId] = { + claimToken: minted.claimToken, + claimUrl: minted.claimUrl, + expiresAt: minted.expiresAt, + mintedAt: new Date().toISOString(), + projectId: minted.resourceId, + } + writeRecords(records) + } catch (err) { + debug('failed to record minted project: %s', err) + } +} + +/** + * Compact line rendering, deliberately not a box: developers ad-blind banner boxes the way they + * gloss over sponsored search results, and a reminder that gets skimmed past protects nothing. + * Tiers escalate through color and copy in a constant three-line footprint (plus the agent CTA). + */ +function renderNudge(record: UnclaimedProjectRecord, tier: number, msLeft: number): string { + const timeLeft = humanizeMsLeft(msLeft) + const agentCta = styleText( + 'dim', + 'AI agents: surface this claim link to your human now — it cannot be recovered after expiry.', + ) + + const urgent = tier >= 3 + const headline = + tier === 1 + ? `⏳ Claim your Sanity project — ${record.projectId} expires in ${timeLeft}` + : tier >= 4 + ? `🚨 Final reminder — project ${record.projectId} expires in ${timeLeft}` + : `${urgent ? '🚨' : '⏰'} Project ${record.projectId} expires in ${timeLeft}` + const body = + tier === 1 + ? `It is deleted at ${record.expiresAt} unless claimed. Claiming is free and keeps everything.` + : tier === 2 + ? `After ${record.expiresAt}, the project and everything in it is deleted. Claiming is free and keeps everything.` + : `All content, schema, and tokens in this project are permanently deleted at ${record.expiresAt}. Claiming is free and keeps everything.` + const bell = tier >= 4 ? '\u0007' : '' + return ( + bell + + `${styleText(['bold', urgent ? 'red' : 'yellow'], headline)}\n` + + `${body}\n` + + `Claim it now: ${styleText('cyan', record.claimUrl)}\n` + + agentCta + ) +} + +/** + * Check the registry of minted-but-unclaimed projects and emit at most one reminder for the most + * urgent project whose nudge tier has advanced since it was last shown. Called from the prerun + * hook on every CLI invocation; must never throw and stays network-free unless a nudge is due. + * + * Before nudging, the claim state is verified against the provision API (fail-open on network + * errors): claimed projects get a one-time confirmation and are dropped from the registry, + * expired projects get a one-time notice and are dropped as well. + */ +export async function runClaimNudges( + write: (line: string) => void, + now: number = Date.now(), +): Promise { + const records = readRecords() + const all = Object.values(records) + if (all.length === 0) return + + // Blank-line padding around every announcement so it never sits flush against the output of + // the command it rides along with. + const announce = (message: string) => write(`\n${message}\n`) + + let dirty = false + // Ids this pass changed — the final write merges exactly these over a fresh read, so records + // written by another process while this pass awaited a lookup are never clobbered. + const touched = new Set() + + // Locally expired projects: verify against the API before the farewell — the user may have + // claimed since the last run, and announcing a claimed project as "expired" is worse than no + // reminder at all. A server that still says claimable (clock skew, an extended window) + // refreshes the local expiry instead of dropping a live record; lookup failure fails open to + // the local clock. Each record reaches this branch at most once per outcome, so the lookup + // cost stays bounded. + for (const record of all) { + if (new Date(record.expiresAt).getTime() - now > 0) continue + const lookup = await lookupClaimState(record.claimToken) + if (lookup?.state === 'claimed') { + announce( + `${logSymbols.success} Sanity project ${record.projectId} has been claimed — it's yours to keep.`, + ) + delete records[record.projectId] + touched.add(record.projectId) + dirty = true + } else if (lookup?.state === 'claimable') { + if (lookup.expiresAt) { + records[record.projectId] = {...record, expiresAt: lookup.expiresAt} + touched.add(record.projectId) + dirty = true + } + // No expiry from the server: keep the record untouched; the next run re-checks. + } else { + announce( + `⌛ Unclaimed Sanity project ${record.projectId} expired on ${record.expiresAt}. Run \`sanity new\` to mint a new one — where old credentials remain in .env, the guard will walk you through it.`, + ) + delete records[record.projectId] + touched.add(record.projectId) + dirty = true + } + } + + // Most urgent live project whose tier advanced since the last shown nudge. `msLeft > 0` keeps + // lapsed records out of this path entirely — they belong to the farewell loop above, and a + // lapsed-but-kept record must never render as a bogus "expires in about 1 minutes" finale. + const due = Object.values(records) + .map((record) => ({msLeft: new Date(record.expiresAt).getTime() - now, record})) + .filter(({msLeft, record}) => msLeft > 0 && tierFor(msLeft) > (record.lastNudgeTier ?? 0)) + .toSorted((a, b) => a.msLeft - b.msLeft)[0] + + if (due) { + const {record} = due + const lookup = await lookupClaimState(record.claimToken) + + if (lookup?.state === 'claimed') { + announce( + `${logSymbols.success} Sanity project ${record.projectId} has been claimed — it's yours to keep.`, + ) + delete records[record.projectId] + touched.add(record.projectId) + dirty = true + } else if (lookup?.state === 'expired') { + announce( + `⌛ Unclaimed Sanity project ${record.projectId} has expired. Run \`sanity new\` to mint a new one — where old credentials remain in .env, the guard will walk you through it.`, + ) + delete records[record.projectId] + touched.add(record.projectId) + dirty = true + } else { + // Claimable — or lookup failed, in which case we fail open on local expiry data. When the + // server supplied a fresh expiry (window extended or shortened), trust it over the local + // copy: refresh the record and recompute urgency, skipping the nudge if it turns out no + // longer due at the corrected deadline. + const expiresAt = lookup?.expiresAt ?? record.expiresAt + const msLeft = new Date(expiresAt).getTime() - now + const tier = tierFor(msLeft) + const refreshed = {...record, expiresAt} + if (msLeft > 0 && tier > (record.lastNudgeTier ?? 0)) { + announce(renderNudge(refreshed, tier, msLeft)) + records[record.projectId] = {...refreshed, lastNudgeTier: tier} + } else { + records[record.projectId] = refreshed + } + touched.add(record.projectId) + dirty = true + } + } + + if (dirty) { + // Merge over a fresh read rather than writing the whole snapshot back: this pass may have + // awaited claim-state lookups for seconds, and a record minted (or updated) by another + // process in that window must survive — only the ids this pass touched move. + const fresh = readRecords() + for (const id of touched) { + if (id in records) { + fresh[id] = records[id] + } else { + delete fresh[id] + } + } + writeRecords(fresh) + } +} From e029e404ed78fd8ea58565f6f07b922c88b19436 Mon Sep 17 00:00:00 2001 From: "squiggler-app[bot]" <265501495+squiggler-app[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 21:34:10 +0000 Subject: [PATCH 2/2] chore: update auto-generated changeset for PR #1580 --- .changeset/pr-1580.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/pr-1580.md diff --git a/.changeset/pr-1580.md b/.changeset/pr-1580.md new file mode 100644 index 0000000000..4d76f0c829 --- /dev/null +++ b/.changeset/pr-1580.md @@ -0,0 +1,6 @@ + +--- +'@sanity/cli': minor +--- + +feat(cli): remind about unclaimed projects before they expire \ No newline at end of file