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-1581.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<!-- auto-generated -->
---
'@sanity/cli': minor
---

feat(init): signpost sanity new to logged-out users
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,8 @@ describe('initAction (direct)', () => {

expect(caughtError).toBeInstanceOf(InitError)
const initError = caughtError as InitError
expect(initError.message).toBe(LOGIN_REQUIRED_MESSAGE)
expect(initError.message).toContain(LOGIN_REQUIRED_MESSAGE)
expect(initError.message).toContain('run `sanity new` to create a project without logging in')
expect(initError.exitCode).toBe(1)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import {afterEach, describe, expect, test, vi} from 'vitest'

import {renderNewCommandBanner} from '../newCommandBanner.js'

function render(): string {
const log = vi.fn()
renderNewCommandBanner({error: vi.fn() as never, log, warn: vi.fn()})
return (
log.mock.calls
.map(([line]) => String(line ?? ''))
.join('\n')
// eslint-disable-next-line no-control-regex -- strip ANSI styling
.replaceAll(/\u001B\[[0-9;]*m/g, '')
// eslint-disable-next-line no-control-regex -- strip OSC 8 hyperlink wrappers
.replaceAll(/\u001B\]8;;[^\u0007]*\u0007/g, '')
)
}

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

describe('#renderNewCommandBanner', () => {
test('renders the two-ways-to-start signpost box, blank-line padded', () => {
const banner = render()

expect(banner).toContain('Two ways to start')
expect(banner).toContain('sanity init')
expect(banner).toContain('sanity new')
expect(banner).toContain('claim it within 72 hours')
expect(banner).toContain('https://sanity.new')
expect(banner).toContain('╭') // boxed — the one-time signpost keeps its box
expect(banner.startsWith('\n')).toBe(true)
expect(banner.endsWith('\n')).toBe(true)
})

test('ignores the retired SANITY_NEW_BANNER switch', () => {
vi.stubEnv('SANITY_NEW_BANNER', '3')

expect(render()).toContain('Two ways to start')
})
})
10 changes: 9 additions & 1 deletion packages/@sanity/cli/src/actions/init/initAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {InitError} from './initError.js'
import {flagOrDefault, shouldPrompt, writeStagingEnvIfNeeded} from './initHelpers.js'
import {initNextJs} from './initNextJs.js'
import {initStudio} from './initStudio.js'
import {renderNewCommandBanner} from './newCommandBanner.js'
import {getPlan} from './plan/getPlan.js'
import {createProjectFromName} from './project/createProjectFromName.js'
import {getProjectDetails} from './project/getProjectDetails.js'
Expand Down Expand Up @@ -397,12 +398,19 @@ async function ensureAuthenticated(
}

if (options.unattended) {
throw new InitError(LOGIN_REQUIRED_MESSAGE, exitCodes.RUNTIME_ERROR)
throw new InitError(
`${LOGIN_REQUIRED_MESSAGE}\n\n` +

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 is likely to be agent read maybe:

"To create a project without logging in, run sanity new (use --json for
machine-readable output). Claim it with a Sanity account within 72 hours
to keep it. Fetch https://sanity.new to learn more."

'Alternatively, run `sanity new` to create a project without logging in — ' +
'you can claim it with a Sanity account later. See `sanity new --help`.',
exitCodes.RUNTIME_ERROR,
)
}

trace.log({step: 'login'})
output.warn(LOGIN_REQUIRED_MESSAGE)

renderNewCommandBanner(output)

try {
await login({
output,
Expand Down
36 changes: 36 additions & 0 deletions packages/@sanity/cli/src/actions/init/newCommandBanner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import {styleText} from 'node:util'

import {boxen} from '@sanity/cli-core/ux'

import {hyperlink as link} from '../../util/terminalLink.js'
import {type InitContext} from './types.js'

const SANITY_NEW_URL = 'https://sanity.new'

/**
* The fork-in-the-road signpost shown before init's interactive login takes over the terminal:
* frames `init` and `new` as two equally valid front doors. A one-time boxed moment is
* deliberate here — unlike the recurring claim reminders, it renders once per init run, so the
* box draws the eye without training anyone to skim past it. (Three prototype treatments
* existed behind a `SANITY_NEW_BANNER` env switch during evaluation; UAT standardized on this
* one and dropped the switch.)
*/
export function renderNewCommandBanner(output: InitContext['output']): void {
output.log('')
output.log(
boxen(
`${styleText('bold', 'Two ways to start')}

${styleText('cyan', 'sanity init')} Log in and set up a Studio ${styleText('dim', "(you're here)")}

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 we can tighten the wording here maybe:

Two ways to start
sanity init Log in/Sign up and make a new project (you're here)
sanity new Create a project without an account. Sign up and claim it within 72 hours to keep it.
AI agents: run sanity new --json to create a new project programatically. Fetch https://sanity.new to learn more.

${styleText('cyan', 'sanity new')} No login — mint a project now, claim it within 72 hours

Learn how it works: ${link(SANITY_NEW_URL, SANITY_NEW_URL)}`,
{
borderColor: 'cyan',
borderStyle: 'round',
padding: 1,
},
),
)
output.log('')
}
Original file line number Diff line number Diff line change
Expand Up @@ -206,14 +206,15 @@ describe('#init: authentication', () => {
})

expect(error?.message).toContain(LOGIN_REQUIRED_MESSAGE)
expect(error?.message).toContain('run `sanity new` to create a project without logging in')
expect(error?.oclif?.exit).toBe(1)
})

test('calls login when token invalid and not in unattended mode', async () => {
mockGetById.mockRejectedValueOnce(createHttpError(401, 'Unauthorized'))

setupInitSuccessMocks()
const {error} = await testCommand(
const {error, stdout} = await testCommand(
InitCommand,
[
'--dataset=test',
Expand All @@ -235,6 +236,8 @@ describe('#init: authentication', () => {
)

if (error) throw error
expect(stdout).toContain('Two ways to start')
expect(stdout).toContain('claim it within 72 hours')
expect(mockLogin).toHaveBeenCalled()
})

Expand Down
Loading