Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
f69650f
feat: add organizations list command
caffeinum May 24, 2026
4bc6ee7
trigger build
caffeinum May 24, 2026
6b7b3a5
fix(cli): improve flag consistency and auth error messages for agents
caffeinum May 24, 2026
7856ffc
fix(cli): add --json flag to projects list command
caffeinum May 25, 2026
6e79800
feat(cli): support background login in non-interactive environments
caffeinum May 26, 2026
30e3a73
fix(cli): fix background login race condition, respect --no-open, add…
caffeinum May 27, 2026
986a58f
fix(cli): address review findings — changeset, mutation, injection, g…
caffeinum May 27, 2026
5949d64
fix(cli): bump changeset to minor, fix test name, add cli-core to cha…
caffeinum May 27, 2026
da03413
fix(cli): config path in output, auth-aware errors, auto-login from init
caffeinum May 27, 2026
e7570e0
feat(cli): add `auth status` command and copy-pasteable hints in erro…
caffeinum May 27, 2026
14e05ba
fix(cli): move --bare + --output-path conflict to init action with hint
caffeinum May 27, 2026
94f4d11
refactor(cli): extract background login child to real file, fix revie…
caffeinum May 27, 2026
e01e852
fix(cli): error with hint instead of auto-selecting provider and org
caffeinum May 27, 2026
cb8a094
Update packages/@sanity/cli/src/actions/deploy/deployStudioSchemasAnd…
caffeinum May 27, 2026
9e4f8ee
Update packages/@sanity/cli/src/commands/schemas/deploy.ts
caffeinum May 27, 2026
6f3ce4e
fix(cli): harden background login pidfile handling
caffeinum May 27, 2026
b6c9a1b
fix: alphabetize imports in deploy schema commands
caffeinum May 27, 2026
5269dcd
trigger build
caffeinum May 27, 2026
bea2818
fix(cli): add hint to --create-project error via catch override
caffeinum May 27, 2026
9e61edf
refactor(cli): extract formatHint helper for consistent hint rendering
caffeinum May 27, 2026
7eb685f
fix(cli): pass default provider when auto-login triggers from init
caffeinum May 27, 2026
2901417
Revert "fix(cli): pass default provider when auto-login triggers from…
caffeinum May 27, 2026
28fa6b0
fix(cli): improve auth error handling and JSON output sorting
caffeinum May 28, 2026
13f1dfb
fix(cli): strengthen background login message to prevent agents killi…
caffeinum May 28, 2026
a480031
feat(cli): add `organizations create`, auto-create org on init, clear…
caffeinum May 28, 2026
a4d244d
fix(cli): frame login message as waiting for user, not agent
caffeinum May 28, 2026
643450b
fix(cli): distinguish "login pending" from "not logged in"
caffeinum May 28, 2026
b2af54c
feat(cli): add `sanity login --wait` to block until login completes
caffeinum May 29, 2026
eb339b5
trigger build
caffeinum May 29, 2026
2d656d7
trigger build
caffeinum May 29, 2026
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/fix-cli-agent-experience.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@sanity/cli': minor
'@sanity/cli-core': minor
---

Add `organizations list` command, improve non-interactive login and project initialization for automated environments
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,12 @@ jq '{total: .numTotalTests, passed: .numPassedTests, failed: .numFailedTests, fi
- Enable debug logs: `DEBUG=sanity:* npx sanity <command>`
- Most commands need to be run within one of the fixture folders.

# Auth Background Login

- `backgroundLoginChild.ts` is spawned dynamically by `backgroundLogin.ts`; keep it listed as a `knip` entry.
- The background login child writes `.auth-callback.json` before printing the login URL, and removes it on exit when the PID and nonce match.
- Token persistence side effects live in `actions/auth/login/storeAuthToken.ts`; use it for both foreground and background login token writes.

## Cursor Cloud specific instructions

- The update script runs `pnpm install --frozen-lockfile` and `pnpm build:cli` on startup. Dependencies and build artifacts should already be up to date when a session begins.
Expand Down
4 changes: 2 additions & 2 deletions knip.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ const baseConfig = {
'src/hooks/**/*.ts',
// Worker files
'src/**/*.worker.ts',
// Spawned dynamically by backgroundLogin.ts
'src/actions/auth/backgroundLoginChild.ts',
'package.config.ts',
],
oclif: {
Expand All @@ -69,8 +71,6 @@ const baseConfig = {
'src/**/*.worker.ts',
'package.config.ts',
],
// debug is used for type checking
ignoreDependencies: ['@types/debug'],
project,
},
'packages/@sanity/cli-core': {
Expand Down
7 changes: 7 additions & 0 deletions packages/@sanity/cli-core/src/SanityCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,13 @@ export abstract class SanityCommand<T extends typeof Command> extends Command {
if (flagProjectId) return flagProjectId
}

// Check --project (hidden alias for --project-id)
const projectAlias =
'project' in this.flags && typeof this.flags.project === 'string'
? this.flags.project
: undefined
if (projectAlias) return projectAlias

// Check deprecated flag (e.g. --project) before CLI config
if (options?.deprecatedFlagName) {
const deprecatedValue =
Expand Down
2 changes: 2 additions & 0 deletions packages/@sanity/cli/oclif.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export default {
},
plugins: ['@oclif/plugin-help', '@sanity/runtime-cli', '@sanity/migrate', '@sanity/codegen'],
topics: {
auth: {description: 'Manage authentication'},
backups: {description: 'Manage dataset backups'},
cors: {description: 'Manage CORS origins for your project'},
datasets: {description: 'Manage datasets in your project'},
Expand All @@ -25,6 +26,7 @@ export default {
mcp: {description: 'Configure Sanity MCP server for AI editors'},
media: {description: 'Manage media assets and aspect definitions'},
openapi: {description: 'Manage OpenAPI specifications'},
organizations: {description: 'Manage Sanity organizations'},
projects: {description: 'Manage Sanity projects'},
schemas: {description: 'Manage and validate schemas'},
telemetry: {description: 'Manage telemetry consent'},
Expand Down
1 change: 1 addition & 0 deletions packages/@sanity/cli/scripts/check-topic-aliases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {topicAliases} from '../src/topicAliases.ts'
// runtime config with topics that will never have aliases.
// ---------------------------------------------------------------------------
const knownTopicsWithoutAliases: Set<string> = new Set([
'auth',
'cors',
'docs',
'graphql',
Expand Down
219 changes: 219 additions & 0 deletions packages/@sanity/cli/src/actions/auth/backgroundLogin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
import {spawn} from 'node:child_process'
import {randomUUID} from 'node:crypto'
import {mkdirSync, readFileSync, unlinkSync, writeFileSync} from 'node:fs'
import {connect} from 'node:net'
import {homedir} from 'node:os'
import {dirname, join} from 'node:path'
import {fileURLToPath} from 'node:url'

import {subdebug} from '@sanity/cli-core'

const debug = subdebug('login:background')
const CHILD_TIMEOUT_MS = 300_000
const PIDFILE_TTL_MS = CHILD_TIMEOUT_MS + 10_000

export function getBackgroundLoginConfigPath(): string {
if (process.env.SANITY_CLI_CONFIG_PATH) {
return process.env.SANITY_CLI_CONFIG_PATH
}
const suffix = process.env.SANITY_INTERNAL_ENV === 'staging' ? '-staging' : ''
return join(homedir(), '.config', `sanity${suffix}`, 'config.json')
}

function getConfigDir(): string {
return dirname(getBackgroundLoginConfigPath())
}

function getPidFilePath(): string {
return join(getConfigDir(), '.auth-callback.json')
}

interface PidFileInfo {
createdAt: number
loginUrl: string
nonce: string
pid: number
port: number
providerUrl: string
}

function readPidFile(): PidFileInfo | null {
try {
const parsed: unknown = JSON.parse(readFileSync(getPidFilePath(), 'utf8'))
if (
!parsed ||
typeof parsed !== 'object' ||
!('createdAt' in parsed) ||
!('loginUrl' in parsed) ||
!('nonce' in parsed) ||
!('pid' in parsed) ||
!('port' in parsed) ||
!('providerUrl' in parsed) ||
typeof parsed.createdAt !== 'number' ||
typeof parsed.loginUrl !== 'string' ||
typeof parsed.nonce !== 'string' ||
typeof parsed.pid !== 'number' ||
typeof parsed.port !== 'number' ||
typeof parsed.providerUrl !== 'string'
) {
return null
}
return {
createdAt: parsed.createdAt,
loginUrl: parsed.loginUrl,
nonce: parsed.nonce,
pid: parsed.pid,
port: parsed.port,
providerUrl: parsed.providerUrl,
}
} catch {
return null
}
}

export function writeBackgroundLoginPidFile(info: PidFileInfo): void {
const dir = getConfigDir()
mkdirSync(dir, {recursive: true})
writeFileSync(getPidFilePath(), JSON.stringify(info))
}

function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0)
return true
} catch {
return false
}
}

function isPidFileFresh(info: PidFileInfo): boolean {
return Date.now() - info.createdAt < PIDFILE_TTL_MS
}

function isPortOpen(port: number): Promise<boolean> {
return new Promise((resolve) => {
const socket = connect({host: '127.0.0.1', port})
const done = (open: boolean) => {
socket.destroy()
resolve(open)
}
socket.setTimeout(500)
socket.once('connect', () => done(true))
socket.once('error', () => done(false))
socket.once('timeout', () => done(false))
})
}

function readChildPort(child: ReturnType<typeof spawn>): Promise<{loginUrl: string; port: number}> {
return new Promise((resolve, reject) => {
let buf = ''
const timeout = setTimeout(() => reject(new Error('Child did not report port in time')), 5000)

child.stdout?.on('data', (chunk: Buffer) => {
buf += chunk.toString()
const lines = buf.split('\n')
if (lines.length >= 2) {
clearTimeout(timeout)
try {
const info = JSON.parse(lines[0])
resolve({loginUrl: info.loginUrl, port: info.port})
} catch {
reject(new Error(`Invalid child output: ${lines[0]}`))
}
}
})

child.on('error', (err) => {
clearTimeout(timeout)
reject(err)
})

child.on('exit', (code) => {
if (code !== 0) {
clearTimeout(timeout)
reject(new Error(`Background login child exited with code ${code}`))
}
})
})
}

/**
* Spawn a detached child that handles the OAuth callback flow.
* Returns immediately with the child's PID, port, and login URL.
*
* If a background login child is already running, returns its info
* instead of spawning a new one (pidfile guard).
*/
export async function startBackgroundLogin(
providerUrl: string,
options: {open?: boolean} = {},
): Promise<{loginUrl: string; pid: number; port: number}> {
const existing = readPidFile()
if (
existing &&
existing.providerUrl === providerUrl &&
isPidFileFresh(existing) &&
isProcessAlive(existing.pid) &&
(await isPortOpen(existing.port))
) {
debug('Background login already running (PID %d, port %d)', existing.pid, existing.port)
return {loginUrl: existing.loginUrl, pid: existing.pid, port: existing.port}
}

const childScript = join(dirname(fileURLToPath(import.meta.url)), 'backgroundLoginChild.js')
const nonce = randomUUID()
const args = [childScript, providerUrl, nonce]
if (options.open !== false) {
args.push('--open')
}

const child = spawn(process.execPath, args, {
detached: true,
env: {...process.env},
stdio: ['ignore', 'pipe', 'ignore'],
})

const {loginUrl, port} = await readChildPort(child)

child.stdout?.destroy()
child.unref()

const pid = child.pid
if (!pid) {
throw new Error('Failed to spawn background login process')
}

debug('Background login child (PID %d) listening on port %d', pid, port)
return {loginUrl, pid, port}
}

export function isBackgroundLoginInProgress(): boolean {
const info = readPidFile()
if (!info) return false
return isPidFileFresh(info) && isProcessAlive(info.pid)
}

export function cancelBackgroundLogin(): {cancelled: boolean; pid?: number} {
const info = readPidFile()
if (!info) {
return {cancelled: false}
}

const pidFilePath = getPidFilePath()
try {
unlinkSync(pidFilePath)
} catch {
// already removed
}

if (isProcessAlive(info.pid)) {
try {
process.kill(info.pid, 'SIGTERM')
} catch {
// already dead
}
return {cancelled: true, pid: info.pid}
}

return {cancelled: false}
}
Loading
Loading