Skip to content

feat(cli): add sanity new to mint unclaimed projects without logging in#1592

Open
shapirodaniel wants to merge 11 commits into
mainfrom
das.feat.mint-and-claim
Open

feat(cli): add sanity new to mint unclaimed projects without logging in#1592
shapirodaniel wants to merge 11 commits into
mainfrom
das.feat.mint-and-claim

Conversation

@shapirodaniel

@shapirodaniel shapirodaniel commented Jul 23, 2026

Copy link
Copy Markdown

Description

this PR adds sanity new as an alias of sanity projects mint. high level, we're allowing humans and agents to create projects without an account, through public apis that produce a claim URL that allows transfer of a newly-created project to the user's desired organization within 72hrs of creation

What's included

  • sanity new mints a new project and issues a robot token that can be used to drive programmatic content lake operations
  • reminders are periodically surfaced on cli commands until the project is claimed or expires
  • sanity init hints at sanity new availability so humans and agents can discover and leverage the new behavior
  • minor changes to existing cli behavior to play nicely with sanity new

For reviewers

we initially gave a stacked diff a try, but the result was a bit unwieldy: sanity new functionality is deep intertwined, and easier to review with agentic support if it's broken into commits on a single diff as per early feedback. we've reshipped as a series of commits instead, and recommend reviewing commit-by-commit or pointing a review agent at the pr to do a holistic pass

Testing

we added unit coverage and conducted manual uat to exercise all branching logic related to credential management

Screenshot 2026-07-23 at 5 41 01 PM Screenshot 2026-07-23 at 5 43 43 PM

Notes for release

Add sanity new to mint an unclaimed Sanity project without logging in, then claim it with a Sanity account later.


Note

High Risk
Changes CLI authentication precedence, stores robot tokens in user config and .env, and adds unauthenticated project provisioning—mistakes could leak credentials or let users act under the wrong identity.

Overview
Introduces sanity new (alias of sanity projects mint) to create an unclaimed Sanity project via the provision API without logging in, returning a robot token and claim URL (72-hour claim window). Interactive runs write credentials to .env, gitignore it when possible, and record the mint in a local unclaimedProjects ledger; --json returns structured data without touching the filesystem.

getCliToken now resolves auth from that ledger (keyed by SANITY_PROJECT_ID in the directory .env) between env SANITY_AUTH_TOKEN and the stored login session, so commands work in a freshly minted folder before Studio config exists. Ledger writes invalidate the in-process token cache like authToken changes.

Supporting pieces include provision mint/claim lookup services, .env helpers with remint guardrails, createFlow UX for the mint narrative, a prerun claimReminders hook (skipped on mint commands), and claim nudges that verify state and remind users to claim.

sanity init advertises sanity new via a “two ways to start” banner when appropriate and tailors unattended auth errors for minted vs credential-heavy directories. login / logout warn when env or ledger robot tokens outrank or survive a session logout; logout calls the API with the stored session token only.

Reviewed by Cursor Bugbot for commit 705c604. Bugbot is set up for automated code reviews on this repo. Configure here.

@github-actions

github-actions Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

📦 Bundle Stats — @sanity/cli

Compared against main (98ae7425)

@sanity/cli

Metric Value vs main (98ae742)
Internal (raw) 2.2 KB -
Internal (gzip) 838 B -
Bundled (raw) 11.20 MB -80 B, -0.0%
Bundled (gzip) 2.11 MB +7 B, +0.0%
Import time 924ms +0ms, +0.0%

bin:sanity

Metric Value vs main (98ae742)
Internal (raw) 782 B -
Internal (gzip) 423 B -
Bundled (raw) 9.90 MB -
Bundled (gzip) 1.78 MB -
Import time 2.24s +1ms, +0.1%

🗺️ View treemap · Artifacts

Details
  • Import time regressions over 10% are flagged with ⚠️
  • Sizes shown as raw / gzip 🗜️. Internal bytes = own code only. Total bytes = with all dependencies. Import time = Node.js cold-start median.

📦 Bundle Stats — @sanity/cli-core

Compared against main (98ae7425)

Metric Value vs main (98ae742)
Internal (raw) 117.2 KB +3.1 KB, +2.7%
Internal (gzip) 30.2 KB +1017 B, +3.4%
Bundled (raw) 21.78 MB +15.1 KB, +0.1%
Bundled (gzip) 3.47 MB +3.6 KB, +0.1%
Import time 819ms -4ms, -0.5%

🗺️ View treemap · Artifacts

Details
  • Import time regressions over 10% are flagged with ⚠️
  • Sizes shown as raw / gzip 🗜️. Internal bytes = own code only. Total bytes = with all dependencies. Import time = Node.js cold-start median.

📦 Bundle Stats — @sanity/cli-build

Compared against main (98ae7425)

@sanity/cli-build/_internal/build

Metric Value vs main (98ae742)
Internal (raw) 113.2 KB -
Internal (gzip) 28.7 KB -
Bundled (raw) 17.75 MB +49 B, +0.0%
Bundled (gzip) 3.55 MB -158 B, -0.0%
Import time 1.16s -4ms, -0.3%

@sanity/cli-build/_internal/env

Metric Value vs main (98ae742)
Internal (raw) 1.8 KB -
Internal (gzip) 644 B -
Bundled (raw) 1.31 MB -
Bundled (gzip) 333.8 KB -
Import time 128ms -0ms, -0.0%

@sanity/cli-build/_internal/extract

Metric Value vs main (98ae742)
Internal (raw) 8.6 KB -
Internal (gzip) 2.7 KB -
Bundled (raw) 155.0 KB -
Bundled (gzip) 39.5 KB -
Import time 253ms -1ms, -0.2%

🗺️ ./_internal/env · ./_internal/extract · @sanity/cli-build:./_internal/build treemap too large to embed · Artifacts

Details
  • Import time regressions over 10% are flagged with ⚠️
  • Sizes shown as raw / gzip 🗜️. Internal bytes = own code only. Total bytes = with all dependencies. Import time = Node.js cold-start median.

📦 Bundle Stats — create-sanity

Compared against main (98ae7425)

Metric Value vs main (98ae742)
Internal (raw) 908 B -
Internal (gzip) 483 B -
Bundled (raw) 931 B -
Bundled (gzip) 491 B -
Import time ❌ ChildProcess denied: node -
Details
  • Import time regressions over 10% are flagged with ⚠️
  • Sizes shown as raw / gzip 🗜️. Internal bytes = own code only. Total bytes = with all dependencies. Import time = Node.js cold-start median.

@shapirodaniel

Copy link
Copy Markdown
Author

bugbot run

Comment thread packages/@sanity/cli/src/commands/logout.ts Outdated
Comment thread packages/@sanity/cli/src/actions/init/initAction.ts Outdated
Comment thread packages/@sanity/cli/src/commands/projects/mint.ts
@github-actions

github-actions Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Coverage Delta

File Statements
packages/@sanity/cli-core/src/config/cli/cliUserConfig.ts 100.0% (±0%)
packages/@sanity/cli-core/src/config/cli/unclaimedProjects.ts 86.7% (new)
packages/@sanity/cli-core/src/ux/flowOutput.ts 94.7% (new)
packages/@sanity/cli/src/actions/init/initAction.ts 91.0% (+ 0.9%)
packages/@sanity/cli/src/actions/init/newCommandBanner.ts 100.0% (new)
packages/@sanity/cli/src/commands/login.ts 100.0% (±0%)
packages/@sanity/cli/src/commands/logout.ts 92.6% (- 7.4%)
packages/@sanity/cli/src/commands/new.ts 100.0% (new)
packages/@sanity/cli/src/commands/projects/mint.ts 98.4% (new)
packages/@sanity/cli/src/hooks/prerun/claimReminders.ts 88.9% (new)
packages/@sanity/cli/src/services/mintProject.ts 95.5% (new)
packages/@sanity/cli/src/util/claimNudges.ts 94.5% (new)
packages/@sanity/cli/src/util/envFile.ts 100.0% (new)
packages/@sanity/cli/src/util/newCommandSplash.ts 100.0% (new)
packages/@sanity/cli/src/util/terminalLink.ts 100.0% (new)

Comparing 15 changed files against main @ efb5705b0ccde1108a2503f921a9cab4f2aa850e

Overall Coverage

Metric Coverage
Statements 79.0% (+ 0.6%)
Branches 70.8% (+ 0.7%)
Functions 74.3% (+ 0.6%)
Lines 79.5% (+ 0.6%)

@shapirodaniel

Copy link
Copy Markdown
Author

bugbot run

Comment thread packages/@sanity/cli/src/commands/projects/mint.ts
Comment thread packages/@sanity/cli/src/commands/projects/mint.ts
Comment thread packages/@sanity/cli-core/src/config/cli/unclaimedProjects.ts
@shapirodaniel

Copy link
Copy Markdown
Author

bugbot run

Comment thread packages/@sanity/cli/src/util/claimNudges.ts
Comment thread packages/@sanity/cli-core/src/config/cli/unclaimedProjects.ts
@shapirodaniel

Copy link
Copy Markdown
Author

bugbot run

Comment thread packages/@sanity/cli/src/util/claimNudges.ts
@shapirodaniel

Copy link
Copy Markdown
Author

bugbot run

Comment thread packages/@sanity/cli/oclif.config.js
@shapirodaniel

Copy link
Copy Markdown
Author

bugbot run

Comment thread packages/@sanity/cli/src/commands/projects/mint.ts Outdated
@shapirodaniel

Copy link
Copy Markdown
Author

bugbot run

Comment thread packages/@sanity/cli/src/commands/projects/mint.ts
@shapirodaniel

Copy link
Copy Markdown
Author

bugbot run

Comment thread packages/@sanity/cli/src/util/claimNudges.ts
Comment thread packages/@sanity/cli/src/actions/init/initAction.ts Outdated
@shapirodaniel

Copy link
Copy Markdown
Author

bugbot run

Comment thread packages/@sanity/cli/src/actions/init/initAction.ts Outdated
@shapirodaniel

Copy link
Copy Markdown
Author

bugbot run

@shapirodaniel

Copy link
Copy Markdown
Author

bugbot run

Comment thread packages/@sanity/cli/src/services/mintProject.ts Outdated
@shapirodaniel

Copy link
Copy Markdown
Author

bugbot run

Comment thread packages/@sanity/cli/src/util/claimNudges.ts Outdated
Comment thread packages/@sanity/cli/src/util/claimNudges.ts Outdated
@shapirodaniel

Copy link
Copy Markdown
Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit b9f1fbb. Configure here.

Resolve the robot token from the minted-project ledger (getCliToken middle
tier) and add the flow-output UX helper used to narrate the mint command.
The ledger parses .env with dotenv (matching readEnvValues) and any ledger
write invalidates the token cache, so a dropped record stops authenticating.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
shapirodaniel and others added 6 commits July 23, 2026 16:44
The provisioning mint + claim-state service (401 reports a revoked token),
the .env writer with gitignore enforcement and git-tracked detection, the
shared GUARDED_ENV_KEYS, and the new-command splash + terminal hyperlinks.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Record minted projects in the local ledger and render expiry-tiered claim
reminders. The cwd project is always claim-verified (so a claimed/expired/
revoked record is dropped and stops outranking the login session), and
post-drop guidance points at working recovery commands.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Mint an unclaimed project without logging in. Writes credentials to .env
(gitignored, with a warning when .env is already tracked or the write
fails), never touches the ledger under --json, and warns if the ledger
write that powers bare-directory auth fails.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Prerun hook that nudges until a minted project is claimed, and sanity init
picking up minted credentials (token-only identity). The two-ways-to-start
banner and the unattended guidance are suppressed wherever the remint guard
would refuse (any guarded .env key present), so neither dead-ends.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Target the stored login session directly, warn when SANITY_AUTH_TOKEN is
set in the environment (logout cannot end it), and surface that a minted
directory keeps acting as the project robot via the ledger token.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
@shapirodaniel
shapirodaniel force-pushed the das.feat.mint-and-claim branch from b9f1fbb to 62a7452 Compare July 23, 2026 20:46
@shapirodaniel
shapirodaniel marked this pull request as ready for review July 23, 2026 21:01
@shapirodaniel
shapirodaniel requested a review from a team as a code owner July 23, 2026 21:01
Comment thread packages/@sanity/cli/src/commands/projects/mint.ts

@jwoods02 jwoods02 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some code comments, doing some manual testing also

In terms of functionally sanity new still doesn't scaffold a project and requires you to run sanity init afterwards. I think we should add that here

: tier >= 4
? `🚨 Final reminder — project ${record.projectId} expires in ${timeLeft}`
: `${urgent ? '🚨' : '⏰'} Project ${record.projectId} expires in ${timeLeft}`
const bell = tier >= 4 ? '' : ''

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both branches are empty strings so the final reminder never actually rings the bell. Either \u0007 in the first branch or delete the variable.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice, imo let's nuke the bell entirely, it's only a human-accessible thing and it's not super differentiated, terminals ring or click for a number of reasons including user inputs

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removed in 4d3af36

switch (state) {
case 'claimable': {
continue
break

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unreachable, the continue above already exits the iteration.

@shapirodaniel shapirodaniel Jul 24, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice catch! removed in 4d3af36

* Resolve the robot token for the minted project the current directory points at, given the
* ledger `records` (the `unclaimedProjects` config value). Lets the CLI authenticate in a freshly
* minted directory that has no config file — where env injection never runs. The token is owned
* by the ledger, not `.env`: `.env` only carries the non-secret project id. Never throws.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't true, mint writes the full robot token into .env as SANITY_AUTH_TOKEN (mint.ts:200), which is why we gitignore it and warn when it's tracked. However this one is true: getCliToken resolves from the ledger rather than .env because .env is mutable.

Worth rewording before someone reads this and concludes .env needs no secret handling. Same wording appears in the getCliToken comment in cliUserConfig.ts.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice catch! reworded in 733d043

// exists — the gap that stops `sanity init` authenticating in a freshly minted directory.
let mintedToken: string | undefined
try {
mintedToken = resolveMintedProjectToken(getUserConfig().get(UNCLAIMED_PROJECTS_CONFIG_KEY))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unsure this is the right approach.

What happens today: inside any sanity project, .env gets loaded into the environment on every command, and an env SANITY_AUTH_TOKEN outranks your login session. That's old behaviour and this PR doesn't touch it. But today you only end up there by putting a token in .env yourself, so it's not a surprise.

What this PR adds: sanity new writes the robot token into .env for you, and this ledger tier makes it win even in a bare directory where env injection doesn't run. So in a minted directory the CLI always prefers the robot token over whoever is actually logged in. Pre-claim that's fair enough, the robot is the only identity that can access the project.

Why I'm unsure it's correct: the preference doesn't really end at claim. The ledger entry gets cleaned up once the claim check succeeds, but the same token is still sitting in .env, and once a studio is scaffolded that file loads into the environment on every command. So in the normal mint → init → claim flow you carry on acting as "Editor Token (Robot)" in that directory forever, even after running sanity login, until you hand-delete the line from .env. And nothing tells you.

Maybe we just need to encode this in docs or the claim message, there could also be issues if we wipe .env for you as well.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we just need to encode this in docs or the claim message

totally agree, in 733d043 we're now warning if an .env token "outranks" the user's session, the same way we warn at sanity logout

in 4d3af36, the final claim "farewell" message gives similar guidance about the directory being robot auth'd and how to switch to user auth

try {
const response = await fetch(url, {
headers: {Authorization: `Bearer ${robotToken}`},
signal: AbortSignal.timeout(options?.timeoutMs ?? 1500),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Default is still 1500 here (and on lookupClaimState at line 61) but every caller now passes 500 after Wednesday's decision. Worth flipping the defaults to 500 so the next caller doesn't quietly get the old timeout.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice catch! fixed in d1739c2


if (!response.ok) {
const body = await response.text().catch(() => '')
throw new Error(`Mint failed (HTTP ${response.status}): ${body || response.statusText}`)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A 404 here means the kill switch is off but it surfaces as Mint failed (HTTP 404): .... The friendly 'provisioning disabled, or rate limited' message below only fires on a 200 with no claimToken, which isn't how the kill switch responds. Suggest special-casing 404 and 429 with the human message.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah def agree on special-casing, i've added human-friendly copy to reference minting as disabled / rate limit reached in d1739c2

const url = `${getProvisionApiBase()}/${PROVISION_API_VERSION}/provision`
debug('minting unclaimed project at %s', url)

const response = await fetch(url, {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there's a HTTP setup in CLI to use. Bare fetch means no proxy support and no CLI user agent, unlike everything else that goes through the client stack.

Someone behind a corporate proxy will have every sanity command work except sanity new, and the server side can't tell CLI mints apart for triage. Fine to keep if it's deliberate for the unauthenticated endpoints, but deserves a comment saying so.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

great catch, and thanks for pointing me to createRequester! updated in d1739c2

expect(mockGetMintedProjectRecord).toHaveBeenCalledWith('abc123')
})

test('env token plus stored session: warns about the env token and ends the session', async () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This proves logout succeeds when both tokens are present, but the nock mock matches on URL only, so it doesn't prove it was the session token rather than SANITY_AUTH_TOKEN that hit /auth/logout. That's the exact leak the change prevents, worth asserting the Authorization header.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

great call out, yeah let's def do this -- updated to assert Authorization header content in 733d043

shapirodaniel and others added 4 commits July 24, 2026 17:36
- after a successful sanity login, warn when SANITY_AUTH_TOKEN (or a
  minted ledger record) still outranks the fresh session, mirroring the
  logout warnings
- correct the token-location comments: .env carries the token for the
  app runtime, the ledger is the CLI's resolution source
- assert the Authorization header in the logout session test

Co-Authored-By: Claude Fable 5 <[email protected]>
Collapse the tiered waterfall and the directory-scoped ambient line
into a single reminder shown on every command, anywhere, from mint
until claim, expiry, or revocation:

- constant copy for the whole window; the headline goes red once 24
  hours remain, and 'Final reminder' is gone
- exact hours-and-minutes countdowns, never 'about'; no emoji
- with several unclaimed projects, the soonest-to-expire leads with the
  full block and the rest aggregate to one compact line each
- claim-state lookups run in parallel and fail open, so the blocking
  prerun check costs one 500ms timeout at most
- the claim farewell gains the robot-token handoff guidance

Co-Authored-By: Claude Fable 5 <[email protected]>
- route mint and claim-state requests through cli-core createRequester
  (CLI user agent), replacing bare fetch
- special-case provision 404 (minting disabled) and 429 (rate limited)
  with plain-language errors; flip lookup timeout defaults to 500ms
- state the claim deadline plainly with the exact timestamp and
  deletion consequence, plus a dim two-line agent handoff
- expand sanity new --help into an agent brief and end with the
  sanity.new next-steps pointer; the splash drops its links

Co-Authored-By: Claude Fable 5 <[email protected]>
- take the reviewed banner wording: log in or sign up vs create
  without an account, claim within 72 hours
- move the agent lines below the box, unboxed and dim like every other
  agent instruction, in the shared 'If an agent is running this' voice
- point the unattended not-logged-in error at sanity new with --json
  and the sanity.new reference

Co-Authored-By: Claude Fable 5 <[email protected]>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 705c604. Configure here.

export {
resolveMintedProjectToken,
UNCLAIMED_PROJECTS_CONFIG_KEY,
} from '../config/cli/unclaimedProjects.js'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New exports on deprecated barrel

Medium Severity

resolveMintedProjectToken and UNCLAIMED_PROJECTS_CONFIG_KEY were added to the root @sanity/cli-core barrel despite its explicit “DO NOT ADD NEW EXPORTS” rule. They already belong on @sanity/cli-core/config, and claimNudges can import them from that subpath instead.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 705c604. Configure here.

@shapirodaniel

Copy link
Copy Markdown
Author

Some code comments, doing some manual testing also

In terms of functionally sanity new still doesn't scaffold a project and requires you to run sanity init afterwards. I think we should add that here

in offline sync, we agreed this is def something we want in v1 launch, and it's a good fit for an immediate fast follow, given the stealth launch of sanity new and the likely additional scope, which would complicate an already pretty large diff

@shapirodaniel
shapirodaniel requested a review from jwoods02 July 24, 2026 21:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants