Skip to content
Open
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
5 changes: 5 additions & 0 deletions packages/@sanity/cli/src/actions/auth/login/login.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
clearCliTokenCache,
type CLITelemetryStore,
getCliToken,
getUserConfig,
Expand All @@ -13,7 +14,7 @@
import {canLaunchBrowser} from '../../../util/canLaunchBrowser.js'
import {startServerForTokenCallback} from '../authServer.js'
import {getBackgroundLoginConfigPath, startBackgroundLogin} from '../backgroundLogin.js'
import {validateSession} from '../ensureAuthenticated.js'

Check failure on line 17 in packages/@sanity/cli/src/actions/auth/login/login.ts

View workflow job for this annotation

GitHub Actions / lint

Dependency cycle detected
import {getProvider} from './getProvider.js'
import {storeAuthToken} from './storeAuthToken.js'
import {validateToken} from './validateToken.js'
Expand Down Expand Up @@ -107,6 +108,10 @@
const interval = 3000
const deadline = Date.now() + maxWait
while (Date.now() < deadline) {
// The background child writes the new token to disk in a separate
// process, which can't invalidate this process's in-memory token cache.
// Clear the cache so validateSession re-reads from disk each iteration.
clearCliTokenCache()
const user = await validateSession()
if (user) {
output.log(`Logged in as ${user.email}.`)
Expand Down
12 changes: 10 additions & 2 deletions packages/@sanity/cli/src/actions/init/initAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import {readFile} from 'node:fs/promises'
import path from 'node:path'
import {styleText} from 'node:util'

import {type SanityOrgUser, subdebug, type TelemetryUserProperties} from '@sanity/cli-core'
import {
clearCliTokenCache,
type SanityOrgUser,
subdebug,
type TelemetryUserProperties,
} from '@sanity/cli-core'
import {logSymbols, spinner} from '@sanity/cli-core/ux'
import {type TelemetryTrace} from '@sanity/telemetry'
import {type Framework, frameworks} from '@vercel/frameworks'
Expand Down Expand Up @@ -402,12 +407,15 @@ async function ensureAuthenticated(
throw new InitError(`Login failed: ${message}`, 1)
}

// Background login returns immediately; poll for the token
// Background login returns immediately; poll for the token. The child
// writes to disk in another process, so clear the in-memory cache each
// iteration to force a fresh disk read.
const maxWait = 120_000
const interval = 3000
const deadline = Date.now() + maxWait
let loggedInUser: SanityOrgUser | null = null
while (Date.now() < deadline) {
clearCliTokenCache()
loggedInUser = await validateSession()
if (loggedInUser) break
await new Promise((r) => setTimeout(r, interval))
Expand Down
4 changes: 2 additions & 2 deletions packages/@sanity/cli/src/commands/__tests__/login.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1321,7 +1321,7 @@ describe('#login', {timeout: 10_000}, () => {
providers: [{name: 'google', title: 'Google', url: 'https://api.sanity.io/auth/google'}],
})

const {error, stdout} = await testCommand(LoginCommand, [])
const {error, stdout} = await testCommand(LoginCommand, ['--no-wait'])

if (error) throw error
expect(stdout).toContain('Opening browser at')
Expand All @@ -1344,7 +1344,7 @@ describe('#login', {timeout: 10_000}, () => {
providers: [{name: 'google', title: 'Google', url: 'https://api.sanity.io/auth/google'}],
})

const {error, stdout} = await testCommand(LoginCommand, ['--no-open'])
const {error, stdout} = await testCommand(LoginCommand, ['--no-open', '--no-wait'])

if (error) throw error
expect(stdout).toContain('Please open a browser at')
Expand Down
20 changes: 19 additions & 1 deletion packages/@sanity/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,15 @@ import {getSanityEnv} from '../util/getSanityEnv.js'

export class InitCommand extends SanityCommand<typeof InitCommand> {
static override args = {type: Args.string({hidden: true})}
static override description = 'Initialize a new Sanity Studio, project and/or app'
static override description = `Initialize a new Sanity Studio, project and/or app.

Common usage (unattended):
sanity init -y --project-name "my-app" --dataset production --output-path ./studio
sanity init -y --bare --project-name "my-app" --organization <id>
sanity init -y --project <existing-id> --dataset production --output-path .

If multiple organizations or providers exist, the CLI prints the IDs with a
copy-pasteable hint. Run 'sanity organizations list' to discover IDs.`
static override enableJsonFlag = true

static override examples = [
Expand All @@ -35,6 +43,11 @@ export class InitCommand extends SanityCommand<typeof InitCommand> {
'<%= config.bin %> <%= command.id %> -y --project-name "Movies Unlimited" --dataset moviedb --visibility private --template moviedb --output-path /Users/espenh/movies-unlimited',
description: 'Create a brand new project with name "Movies Unlimited"',
},
{
command: 'cd my-studio && sanity typegen generate',
description:
'After init, generate TypeScript types from your schema (avoid hand-writing interfaces)',
},
] satisfies Array<Command.Example>

static override flags = {
Expand All @@ -53,6 +66,7 @@ export class InitCommand extends SanityCommand<typeof InitCommand> {
'Optionally select a coupon for a new project (cannot be used with --project-plan)',
exclusive: ['project-plan'],
helpValue: '<code>',
hidden: true,
}),
'create-project': Flags.string({
deprecated: {message: 'Use --project-name instead'},
Expand Down Expand Up @@ -95,11 +109,13 @@ export class InitCommand extends SanityCommand<typeof InitCommand> {
allowNo: true,
default: undefined,
description: 'Import template sample dataset',
hidden: true,
}),
mcp: Flags.boolean({
allowNo: true,
default: true,
description: 'Enable AI editor integration (MCP) setup',
hidden: true,
}),
'nextjs-add-config-files': Flags.boolean({
allowNo: true,
Expand Down Expand Up @@ -139,6 +155,7 @@ export class InitCommand extends SanityCommand<typeof InitCommand> {
allowNo: true,
default: undefined,
description: 'Overwrite existing files',
hidden: true,
}),
'package-manager': Flags.string({
description: 'Specify which package manager to use [allowed: npm, yarn, pnpm]',
Expand All @@ -160,6 +177,7 @@ export class InitCommand extends SanityCommand<typeof InitCommand> {
'project-plan': Flags.string({
description: 'Optionally select a plan for a new project',
helpValue: '<name>',
hidden: true,
}),
provider: Flags.string({
description: 'Login provider to use',
Expand Down
14 changes: 11 additions & 3 deletions packages/@sanity/cli/src/commands/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,9 @@ authenticated, this completes in seconds.`
helpValue: '<name>',
}),
wait: Flags.boolean({
default: false,
description: 'Block until login completes (up to 5 minutes). Recommended for agents and CI.',
allowNo: true,
description:
'Block until login completes (up to 5 minutes). Defaults to true in non-interactive contexts (CI, agents); pass `--no-wait` to opt out.',
}),
'with-token': Flags.boolean({
description: 'Read token from standard input',
Expand All @@ -82,6 +83,12 @@ authenticated, this completes in seconds.`
const {flags} = await this.parse(LoginCommand)
const {'sso-provider': ssoProvider, 'with-token': withToken, ...loginFlags} = flags

// Default `--wait` to true when not interactive and not using --with-token,
// so agents and CI get a deterministic exit code without having to discover
// the flag. Pass `--no-wait` to opt out.
const effectiveWait =
typeof flags.wait === 'boolean' ? flags.wait : !isInteractive() && !withToken
Comment on lines +89 to +90
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Refresh the token cache before default waiting

When non-interactive sanity login is run without --wait/--no-wait and the config already contains an invalid or expired authToken, this new default enters the wait loop after login() has already called getCliToken(), seeding the parent process token cache with the stale token. The background child writes the new token in a separate process, so it cannot clear the parent's cache; subsequent validateSession() calls keep checking the old token and the command times out after 5 minutes even though OAuth completed. Clear the CLI token cache or read the config fresh before/during the wait loop.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Don't treat the implicit false as an explicit wait choice

In non-interactive sanity login without --wait/--no-wait, oclif boolean flags still parse as false when omitted (the Flags.boolean implementation documents that booleans default to false unless defaulted true), so typeof flags.wait === 'boolean' is always true and effectiveWait becomes false instead of falling through to !isInteractive() && !withToken. This means the new advertised default never applies for agents/CI unless they still discover and pass --wait, preserving the old immediate-return behavior and making --no-wait indistinguishable from omission.

Useful? React with 👍 / 👎.


try {
const token = withToken ? await readTokenFromStdin() : undefined

Expand All @@ -91,9 +98,10 @@ authenticated, this completes in seconds.`
ssoProvider,
telemetry: this.telemetry,
token,
wait: effectiveWait,
})

if (isInteractive() || token || flags.wait) {
if (isInteractive() || token || effectiveWait) {
this.log('Login successful')
}
} catch (error) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ describe('#tokens:add', () => {
expect(stdout).toContain('Role: Viewer')
expect(stdout).toContain('Token: sk_test_abcd1234')
expect(stdout).toContain('Copy the token above')
expect(stdout).toContain('SANITY_API_TOKEN=sk_test_abcd1234')
})

test('creates token with specific role', async () => {
Expand Down
3 changes: 3 additions & 0 deletions packages/@sanity/cli/src/commands/tokens/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,9 @@ export class AddTokenCommand extends SanityCommand<typeof AddTokenCommand> {
this.log(`Token: ${token.key}`)
this.log('')
this.log('Copy the token above – this is your only chance to do so!')
this.log('')
this.log('Add to .env (or your environment) for use with @sanity/client:')
this.log(` SANITY_API_TOKEN=${token.key}`)
} catch (error) {
const err = error as Error

Expand Down
Loading