From a241b0466bd1ad15dae25bc8fdb3d8d2a2b9db72 Mon Sep 17 00:00:00 2001 From: Aleksey Bykhun Date: Fri, 29 May 2026 15:50:35 -0700 Subject: [PATCH 1/3] feat(cli): default --wait in non-interactive mode, slim init --help Based on eval trace sanity-22a3b1e3 analysis: - Make `--wait` default to true when stdout is not a TTY (CI, agents), unless --with-token is used. Pass --no-wait to opt out. Agents no longer have to discover the flag and guess `sleep 15` cycles. - Slim `sanity init --help` by hiding niche flags (coupon, project-plan, mcp, import-dataset, overwrite-files) and adding a "Common usage" block to the command description. Surfaces the typical workflow before the full flag list. Co-Authored-By: Claude Opus 4.7 --- .../cli/src/commands/__tests__/login.test.ts | 4 ++-- packages/@sanity/cli/src/commands/init.ts | 15 ++++++++++++++- packages/@sanity/cli/src/commands/login.ts | 14 +++++++++++--- 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/packages/@sanity/cli/src/commands/__tests__/login.test.ts b/packages/@sanity/cli/src/commands/__tests__/login.test.ts index 8f9c09708..16afa5b4b 100644 --- a/packages/@sanity/cli/src/commands/__tests__/login.test.ts +++ b/packages/@sanity/cli/src/commands/__tests__/login.test.ts @@ -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') @@ -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') diff --git a/packages/@sanity/cli/src/commands/init.ts b/packages/@sanity/cli/src/commands/init.ts index 4afc5c08e..c627786ea 100644 --- a/packages/@sanity/cli/src/commands/init.ts +++ b/packages/@sanity/cli/src/commands/init.ts @@ -10,7 +10,15 @@ import {getSanityEnv} from '../util/getSanityEnv.js' export class InitCommand extends SanityCommand { 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 + sanity init -y --project --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 = [ @@ -53,6 +61,7 @@ export class InitCommand extends SanityCommand { 'Optionally select a coupon for a new project (cannot be used with --project-plan)', exclusive: ['project-plan'], helpValue: '', + hidden: true, }), 'create-project': Flags.string({ deprecated: {message: 'Use --project-name instead'}, @@ -95,11 +104,13 @@ export class InitCommand extends SanityCommand { 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, @@ -139,6 +150,7 @@ export class InitCommand extends SanityCommand { 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]', @@ -160,6 +172,7 @@ export class InitCommand extends SanityCommand { 'project-plan': Flags.string({ description: 'Optionally select a plan for a new project', helpValue: '', + hidden: true, }), provider: Flags.string({ description: 'Login provider to use', diff --git a/packages/@sanity/cli/src/commands/login.ts b/packages/@sanity/cli/src/commands/login.ts index 876fc0106..b1a28f6ba 100644 --- a/packages/@sanity/cli/src/commands/login.ts +++ b/packages/@sanity/cli/src/commands/login.ts @@ -69,8 +69,9 @@ authenticated, this completes in seconds.` helpValue: '', }), 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', @@ -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 + try { const token = withToken ? await readTokenFromStdin() : undefined @@ -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) { From f3a5c2ed8062fca6ec97edfc1c86b7aec88cdcd6 Mon Sep 17 00:00:00 2001 From: Aleksey Bykhun Date: Fri, 29 May 2026 17:10:09 -0700 Subject: [PATCH 2/3] fix(cli): clear token cache between wait-loop iterations The background login child writes the new token to disk in a separate process, so it cannot invalidate the parent process's in-memory token cache. If the parent had an existing (stale/expired) token at startup, `getCliToken()` returns the cached stale value indefinitely and the wait loop times out after 5 minutes even though OAuth completed. Call clearCliTokenCache() before each validateSession() iteration in both the login --wait loop and the initAction auto-login polling, so the next call re-reads from disk. Reported by CodeRabbit on PR #11. Co-Authored-By: Claude Opus 4.7 --- packages/@sanity/cli/src/actions/auth/login/login.ts | 5 +++++ packages/@sanity/cli/src/actions/init/initAction.ts | 12 ++++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/@sanity/cli/src/actions/auth/login/login.ts b/packages/@sanity/cli/src/actions/auth/login/login.ts index 9138415ce..a77b4baed 100644 --- a/packages/@sanity/cli/src/actions/auth/login/login.ts +++ b/packages/@sanity/cli/src/actions/auth/login/login.ts @@ -1,4 +1,5 @@ import { + clearCliTokenCache, type CLITelemetryStore, getCliToken, getUserConfig, @@ -107,6 +108,10 @@ export async function login(options: LoginOptions) { 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}.`) diff --git a/packages/@sanity/cli/src/actions/init/initAction.ts b/packages/@sanity/cli/src/actions/init/initAction.ts index 9d7113c76..60194f21c 100644 --- a/packages/@sanity/cli/src/actions/init/initAction.ts +++ b/packages/@sanity/cli/src/actions/init/initAction.ts @@ -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' @@ -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)) From da272cd525bd0aad033ec1c2f692188bd1880892 Mon Sep 17 00:00:00 2001 From: Aleksey Bykhun Date: Fri, 29 May 2026 19:05:01 -0700 Subject: [PATCH 3/3] feat(cli): emit env snippet from `tokens add`, link typegen from init --help MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two small AX wins from eval trace sanity-95d47728: - `sanity tokens add` now prints a copy-pasteable `SANITY_API_TOKEN=...` env line after the token. Agents commonly created a token then wrote a seed script with raw `@sanity/client` — the env name nudge skips one round of "what should I name this var?". - `sanity init --help` now includes `cd my-studio && sanity typegen generate` as an example. In the eval trace the agent hand-wrote a TypeScript interface mirroring the schema; typegen would have generated it (and kept it in sync). Co-Authored-By: Claude Opus 4.7 --- packages/@sanity/cli/src/commands/init.ts | 5 +++++ .../@sanity/cli/src/commands/tokens/__tests__/add.test.ts | 1 + packages/@sanity/cli/src/commands/tokens/add.ts | 3 +++ 3 files changed, 9 insertions(+) diff --git a/packages/@sanity/cli/src/commands/init.ts b/packages/@sanity/cli/src/commands/init.ts index c627786ea..941ea93fc 100644 --- a/packages/@sanity/cli/src/commands/init.ts +++ b/packages/@sanity/cli/src/commands/init.ts @@ -43,6 +43,11 @@ copy-pasteable hint. Run 'sanity organizations list' to discover IDs.` '<%= 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 static override flags = { diff --git a/packages/@sanity/cli/src/commands/tokens/__tests__/add.test.ts b/packages/@sanity/cli/src/commands/tokens/__tests__/add.test.ts index ab7d9328c..2a04f18e5 100644 --- a/packages/@sanity/cli/src/commands/tokens/__tests__/add.test.ts +++ b/packages/@sanity/cli/src/commands/tokens/__tests__/add.test.ts @@ -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 () => { diff --git a/packages/@sanity/cli/src/commands/tokens/add.ts b/packages/@sanity/cli/src/commands/tokens/add.ts index c64695bbf..1f58de752 100644 --- a/packages/@sanity/cli/src/commands/tokens/add.ts +++ b/packages/@sanity/cli/src/commands/tokens/add.ts @@ -99,6 +99,9 @@ export class AddTokenCommand extends SanityCommand { 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