diff --git a/.changeset/fix-cli-agent-experience.md b/.changeset/fix-cli-agent-experience.md new file mode 100644 index 000000000..808d417bf --- /dev/null +++ b/.changeset/fix-cli-agent-experience.md @@ -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 diff --git a/AGENTS.md b/AGENTS.md index a691bef06..fed228200 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -163,6 +163,12 @@ jq '{total: .numTotalTests, passed: .numPassedTests, failed: .numFailedTests, fi - Enable debug logs: `DEBUG=sanity:* npx sanity ` - 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. diff --git a/knip.config.ts b/knip.config.ts index 6719df355..ddd0e834d 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -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: { @@ -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': { diff --git a/packages/@sanity/cli-core/src/SanityCommand.ts b/packages/@sanity/cli-core/src/SanityCommand.ts index 3448e49ea..aaf1b5784 100644 --- a/packages/@sanity/cli-core/src/SanityCommand.ts +++ b/packages/@sanity/cli-core/src/SanityCommand.ts @@ -139,6 +139,13 @@ export abstract class SanityCommand 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 = diff --git a/packages/@sanity/cli/oclif.config.js b/packages/@sanity/cli/oclif.config.js index 7c2245123..9acecfebd 100644 --- a/packages/@sanity/cli/oclif.config.js +++ b/packages/@sanity/cli/oclif.config.js @@ -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'}, @@ -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'}, diff --git a/packages/@sanity/cli/scripts/check-topic-aliases.ts b/packages/@sanity/cli/scripts/check-topic-aliases.ts index e9f4b9bfe..2f704de09 100644 --- a/packages/@sanity/cli/scripts/check-topic-aliases.ts +++ b/packages/@sanity/cli/scripts/check-topic-aliases.ts @@ -28,6 +28,7 @@ import {topicAliases} from '../src/topicAliases.ts' // runtime config with topics that will never have aliases. // --------------------------------------------------------------------------- const knownTopicsWithoutAliases: Set = new Set([ + 'auth', 'cors', 'docs', 'graphql', diff --git a/packages/@sanity/cli/src/actions/auth/backgroundLogin.ts b/packages/@sanity/cli/src/actions/auth/backgroundLogin.ts new file mode 100644 index 000000000..e01e35ee6 --- /dev/null +++ b/packages/@sanity/cli/src/actions/auth/backgroundLogin.ts @@ -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 { + 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): 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} +} diff --git a/packages/@sanity/cli/src/actions/auth/backgroundLoginChild.ts b/packages/@sanity/cli/src/actions/auth/backgroundLoginChild.ts new file mode 100644 index 000000000..d3be1fad8 --- /dev/null +++ b/packages/@sanity/cli/src/actions/auth/backgroundLoginChild.ts @@ -0,0 +1,107 @@ +/** + * Standalone entry point for the background login child process. + * Spawned detached by `startBackgroundLogin` — do not import directly. + * + * Usage: node backgroundLoginChild.js [--open] + * + * 1. Starts a callback server (reuses authServer.ts logic) + * 2. Optionally opens the login URL in a browser + * 3. Reports the port and login URL as JSON on stdout + * 4. Waits for OAuth callback + * 5. Writes the token to CLI config + * 6. Exits + */ +import {existsSync, readFileSync, unlinkSync} from 'node:fs' +import {dirname, join} from 'node:path' + +import {getCliToken} from '@sanity/cli-core' +import open from 'open' + +import {startServerForTokenCallback} from './authServer.js' +import {getBackgroundLoginConfigPath, writeBackgroundLoginPidFile} from './backgroundLogin.js' +import {storeAuthToken} from './login/storeAuthToken.js' + +const TIMEOUT_MS = 300_000 + +const providerUrl = process.argv[2] +if (!providerUrl) { + process.stderr.write('Usage: backgroundLoginChild.js [--open]\n') + process.exit(1) +} +const nonce = process.argv[3] +if (!nonce) { + process.stderr.write('Usage: backgroundLoginChild.js [--open]\n') + process.exit(1) +} + +const shouldOpen = process.argv.includes('--open') +const pidFilePath = join(dirname(getBackgroundLoginConfigPath()), '.auth-callback.json') + +function cleanupPidFile(): void { + if (!existsSync(pidFilePath)) return + try { + const parsed: unknown = JSON.parse(readFileSync(pidFilePath, 'utf8')) + if ( + parsed && + typeof parsed === 'object' && + 'pid' in parsed && + 'nonce' in parsed && + parsed.pid === process.pid && + parsed.nonce === nonce + ) { + unlinkSync(pidFilePath) + } + } catch { + return + } +} + +process.once('exit', cleanupPidFile) +process.once('SIGTERM', () => { + cleanupPidFile() + process.exit(1) +}) + +const {loginUrl, server, token: tokenPromise} = await startServerForTokenCallback(providerUrl) +const port = (server.address() as {port: number}).port + +writeBackgroundLoginPidFile({ + createdAt: Date.now(), + loginUrl: loginUrl.href, + nonce, + pid: process.pid, + port, + providerUrl, +}) + +process.stdout.write(JSON.stringify({loginUrl: loginUrl.href, port}) + '\n') + +if (shouldOpen) { + try { + await open(loginUrl.href) + } catch (error) { + void error + } +} + +const timeout = setTimeout(() => { + server.close() + process.exit(1) +}, TIMEOUT_MS) + +try { + const previousToken = await getCliToken() + const {token} = await tokenPromise + await storeAuthToken(token, previousToken, { + warn: (message: Error | string) => { + return message + }, + }) + clearTimeout(timeout) + server.close() + process.exit(0) +} catch { + clearTimeout(timeout) + server.close() + process.exit(1) +} diff --git a/packages/@sanity/cli/src/actions/auth/login/__tests__/login.vercel.test.ts b/packages/@sanity/cli/src/actions/auth/login/__tests__/login.vercel.test.ts index 9c098aeb7..b19d2d249 100644 --- a/packages/@sanity/cli/src/actions/auth/login/__tests__/login.vercel.test.ts +++ b/packages/@sanity/cli/src/actions/auth/login/__tests__/login.vercel.test.ts @@ -17,6 +17,7 @@ vi.mock('@sanity/cli-core', async (importOriginal) => { get: vi.fn(), set: vi.fn(), }), + isInteractive: vi.fn().mockReturnValue(true), setCliUserConfig: vi.fn(), subdebug: vi.fn(() => vi.fn()), } @@ -44,6 +45,9 @@ vi.mock('../validateToken.js', () => ({ vi.mock('../../../../util/canLaunchBrowser.js', () => ({ canLaunchBrowser: vi.fn(() => true), })) +vi.mock('../../backgroundLogin.js', () => ({ + startBackgroundLogin: vi.fn(), +})) const mockedGetCliToken = vi.mocked(getCliToken) const mockedSetCliUserConfig = vi.mocked(setCliUserConfig) diff --git a/packages/@sanity/cli/src/actions/auth/login/getProvider.ts b/packages/@sanity/cli/src/actions/auth/login/getProvider.ts index 805f0dd75..a8d0514ad 100644 --- a/packages/@sanity/cli/src/actions/auth/login/getProvider.ts +++ b/packages/@sanity/cli/src/actions/auth/login/getProvider.ts @@ -3,6 +3,7 @@ import {input, spinner, type SpinnerInstance} from '@sanity/cli-core/ux' import {promptForProviders} from '../../../prompts/promptForProviders.js' import {getProviders, getVercelProviderUrl} from '../../../services/auth.js' +import {formatHint} from '../../../util/formatHint.js' import {type LoginProvider} from '../types.js' import {getSSOProvider} from './getSSOProvider.js' @@ -71,8 +72,8 @@ export async function getProvider({ if (!isInteractive() && realProviderNames.length > 1) { throw new Error( - `Multiple login providers available: ${realProviderNames.join(', ')}. ` + - 'Use `--provider ` to select one in unattended mode.', + `Multiple login providers available: ${realProviderNames.join(', ')}.` + + formatHint(`sanity login --provider ${realProviderNames[0]}`), ) } diff --git a/packages/@sanity/cli/src/actions/auth/login/login.ts b/packages/@sanity/cli/src/actions/auth/login/login.ts index beeb6e3d9..9138415ce 100644 --- a/packages/@sanity/cli/src/actions/auth/login/login.ts +++ b/packages/@sanity/cli/src/actions/auth/login/login.ts @@ -2,20 +2,21 @@ import { type CLITelemetryStore, getCliToken, getUserConfig, + isInteractive, type Output, - setCliUserConfig, subdebug, } from '@sanity/cli-core' import {spinner} from '@sanity/cli-core/ux' -import {isHttpError} from '@sanity/client' import open from 'open' -import {logout} from '../../../services/auth.js' import {LoginTrace} from '../../../telemetry/login.telemetry.js' import {canLaunchBrowser} from '../../../util/canLaunchBrowser.js' import {startServerForTokenCallback} from '../authServer.js' +import {getBackgroundLoginConfigPath, startBackgroundLogin} from '../backgroundLogin.js' +import {validateSession} from '../ensureAuthenticated.js' import {getProvider} from './getProvider.js' -import {isSanityApiToken, validateToken} from './validateToken.js' +import {storeAuthToken} from './storeAuthToken.js' +import {validateToken} from './validateToken.js' const debug = subdebug('login') @@ -30,6 +31,7 @@ interface LoginOptions { sso?: string ssoProvider?: string token?: string + wait?: boolean } /** @@ -75,22 +77,78 @@ export async function login(options: LoginOptions) { throw new Error('No authentication providers found') } + // In non-interactive mode (CI, containers, AI agents), self-background the + // callback server so the CLI returns immediately. The browser-agent or user + // completes OAuth in the background; the token is written to config when the + // callback fires. The caller can retry `sanity init` until auth succeeds. + if (!isInteractive()) { + // Pre-populate telemetryDisclosed so the next CLI command's prerun hook + // doesn't do a read-modify-write that clobbers the token the child writes. + const userConfig = getUserConfig() + if (!userConfig.get('telemetryDisclosed')) { + userConfig.set('telemetryDisclosed', Date.now()) + } + + const shouldOpen = options.open !== false + const {loginUrl, pid, port} = await startBackgroundLogin(provider.url, {open: shouldOpen}) + + if (shouldOpen) { + output.log(`\nOpening browser at ${loginUrl}\n`) + } else { + output.log(`\nPlease open a browser at ${loginUrl}\n`) + } + debug('Background login child PID %d listening on port %d', pid, port) + + if (options.wait) { + output.log( + `Authentication is running in the background. Waiting for the user to complete the login in their browser...\n`, + ) + const maxWait = 300_000 + const interval = 3000 + const deadline = Date.now() + maxWait + while (Date.now() < deadline) { + const user = await validateSession() + if (user) { + output.log(`Logged in as ${user.email}.`) + trace.complete() + return + } + await new Promise((r) => setTimeout(r, interval)) + } + throw new Error( + 'Login timed out after 5 minutes. Run `sanity auth status` to check, or `sanity auth cancel` to stop.', + ) + } + + output.log(`Authentication is running in the background.`) + output.log( + `Wait for the user to complete the login in their browser. Token saves to ${getBackgroundLoginConfigPath()} when done.`, + ) + output.log('') + output.log(`Wait ~30-60 seconds, then check: sanity auth status`) + output.log(`To switch providers or cancel: sanity auth cancel`) + output.log(`Or rerun with \`--wait\` to block until login completes.\n`) + + trace.complete() + return + } + const {loginUrl, server, token: tokenPromise} = await startServerForTokenCallback(provider.url) trace.log({step: 'waitForToken'}) // Open a browser on the login page (or tell the user to) const shouldLaunchBrowser = canLaunchBrowser() && options.open !== false - const actionText = shouldLaunchBrowser ? 'Opening browser at' : 'Please open a browser at' - - output.log(`\n${actionText} ${loginUrl.href}\n`) - - const spin = spinner('Waiting for browser login to complete... Press Ctrl + C to cancel').start() if (shouldLaunchBrowser) { open(loginUrl.href) + output.log(`\nOpening browser at ${loginUrl.href}\n`) + } else { + output.log(`\nPlease open a browser at ${loginUrl.href}\n`) } + const spin = spinner('Waiting for browser login to complete... Press Ctrl + C to cancel').start() + // Wait for a success/error on the HTTP callback server let authToken: string try { @@ -111,33 +169,3 @@ export async function login(options: LoginOptions) { trace.complete() } - -async function storeAuthToken( - authToken: string, - previousToken: string | undefined, - output: Output, -) { - setCliUserConfig('authToken', authToken) - getUserConfig().delete('telemetryConsent') - - // If we had a session previously, attempt to clear it - if (previousToken && previousToken !== authToken) { - await invalidateAuthToken(previousToken, output) - } -} - -async function invalidateAuthToken(token: string, output: Output) { - try { - if (await isSanityApiToken(token)) return - } catch (err) { - if (isHttpError(err) && err.statusCode === 401) return - } - - try { - await logout(token) - } catch (err) { - if (!isHttpError(err) || err.statusCode !== 401) { - output.warn('Failed to invalidate previous session') - } - } -} diff --git a/packages/@sanity/cli/src/actions/auth/login/storeAuthToken.ts b/packages/@sanity/cli/src/actions/auth/login/storeAuthToken.ts new file mode 100644 index 000000000..e7cc7d978 --- /dev/null +++ b/packages/@sanity/cli/src/actions/auth/login/storeAuthToken.ts @@ -0,0 +1,34 @@ +import {getUserConfig, type Output, setCliUserConfig} from '@sanity/cli-core' +import {isHttpError} from '@sanity/client' + +import {logout} from '../../../services/auth.js' +import {isSanityApiToken} from './validateToken.js' + +export async function storeAuthToken( + authToken: string, + previousToken: string | undefined, + output: Pick, +) { + setCliUserConfig('authToken', authToken) + getUserConfig().delete('telemetryConsent') + + if (previousToken && previousToken !== authToken) { + await invalidateAuthToken(previousToken, output) + } +} + +async function invalidateAuthToken(token: string, output: Pick) { + try { + if (await isSanityApiToken(token)) return + } catch (err) { + if (isHttpError(err) && err.statusCode === 401) return + } + + try { + await logout(token) + } catch (err) { + if (!isHttpError(err) || err.statusCode !== 401) { + output.warn('Failed to invalidate previous session') + } + } +} diff --git a/packages/@sanity/cli/src/actions/debug/gatherDebugInfo.ts b/packages/@sanity/cli/src/actions/debug/gatherDebugInfo.ts index d2fff023c..d0c5e0b5c 100644 --- a/packages/@sanity/cli/src/actions/debug/gatherDebugInfo.ts +++ b/packages/@sanity/cli/src/actions/debug/gatherDebugInfo.ts @@ -10,6 +10,7 @@ import { import {getProjectById} from '../../services/projects.js' import {getCliUser, getProjectUser} from '../../services/user.js' +import {isBackgroundLoginInProgress} from '../auth/backgroundLogin.js' import {getCliVersion} from '../../util/getCliVersion.js' import {detectCliInstallation} from '../../util/packageManager/installationInfo/index.js' import { @@ -24,6 +25,11 @@ import { export async function gatherUserInfo(projectId: string | undefined): Promise { const token = await getCliToken() if (!token) { + if (isBackgroundLoginInProgress()) { + return new Error( + 'Login pending — callback server is running, waiting for OAuth redirect. Wait 30-60 seconds and re-check, or run `sanity auth cancel` to stop.', + ) + } return new Error('Not logged in') } diff --git a/packages/@sanity/cli/src/actions/deploy/deployStudioSchemasAndManifests.ts b/packages/@sanity/cli/src/actions/deploy/deployStudioSchemasAndManifests.ts index 4a17e0690..379cdc5f0 100644 --- a/packages/@sanity/cli/src/actions/deploy/deployStudioSchemasAndManifests.ts +++ b/packages/@sanity/cli/src/actions/deploy/deployStudioSchemasAndManifests.ts @@ -1,7 +1,7 @@ import {styleText} from 'node:util' -import {SchemaExtractionError} from '@sanity/cli-build/_internal' import {ux} from '@oclif/core/ux' +import {SchemaExtractionError} from '@sanity/cli-build/_internal' import {getCliTelemetry, studioWorkerTask, subdebug} from '@sanity/cli-core' import {type SchemaValidationProblemGroup} from '@sanity/types' import {type StudioManifest} from 'sanity' diff --git a/packages/@sanity/cli/src/actions/init/__tests__/initAction.test.ts b/packages/@sanity/cli/src/actions/init/__tests__/initAction.test.ts index c35794609..0a149a66f 100644 --- a/packages/@sanity/cli/src/actions/init/__tests__/initAction.test.ts +++ b/packages/@sanity/cli/src/actions/init/__tests__/initAction.test.ts @@ -1,8 +1,14 @@ +import {mkdtempSync, rmSync, writeFileSync} from 'node:fs' +import {tmpdir} from 'node:os' +import path from 'node:path' + import {createTestClient, mockApi} from '@sanity/cli-test' import nock from 'nock' import {afterEach, describe, expect, test, vi} from 'vitest' import {PROJECT_FEATURES_API_VERSION} from '../../../services/getProjectFeatures.js' +import {ORGANIZATIONS_API_VERSION} from '../../../services/organizations.js' +import {CREATE_PROJECT_API_VERSION} from '../../../services/projects.js' import {initAction} from '../initAction.js' import {InitError} from '../initError.js' import {type InitContext, type InitOptions} from '../types.js' @@ -49,6 +55,7 @@ vi.mock('@sanity/cli-core', async (importOriginal) => { return { datasets: { + create: vi.fn().mockResolvedValue(undefined), list: vi.fn().mockResolvedValue([{aclMode: 'public', name: 'production'}]), }, request: client.request, @@ -82,7 +89,7 @@ const defaultOptions: InitOptions = { unattended: false, } -function createTestContext(): InitContext { +function createTestContext(workDir = '/tmp/test-work-dir'): InitContext { return { output: { // output.error has a `never` return type in the Output interface, but @@ -101,7 +108,7 @@ function createTestContext(): InitContext { start: vi.fn(), }), } as unknown as InitContext['telemetry'], - workDir: '/tmp/test-work-dir', + workDir, } } @@ -178,8 +185,9 @@ describe('initAction (direct)', () => { expect(combined).toContain('production') }) - test('throws InitError when not authenticated in unattended mode', async () => { + test('auto-triggers login when not authenticated in unattended mode', async () => { mockValidateSession.mockResolvedValue(null) + mockLogin.mockRejectedValue(new Error('No providers available')) const context = createTestContext() const options: InitOptions = { @@ -197,11 +205,275 @@ describe('initAction (direct)', () => { caughtError = error } + expect(mockLogin).toHaveBeenCalled() expect(caughtError).toBeInstanceOf(InitError) const initError = caughtError as InitError - expect(initError.message).toBe( - 'Must be logged in to run this command in unattended mode, run `sanity login`', - ) + expect(initError.message).toContain('Login failed') expect(initError.exitCode).toBe(1) }) + + test('unattended --project-name with single org with attach grant auto-picks org', async () => { + mockValidateSession.mockResolvedValue({ + email: 'test@example.com', + id: 'user-123', + name: 'Test User', + provider: 'google', + }) + + mockApi({ + apiVersion: ORGANIZATIONS_API_VERSION, + uri: '/organizations', + }).reply(200, [{id: 'org-1', name: 'Only Org', slug: 'only-org'}]) + + mockApi({ + apiVersion: ORGANIZATIONS_API_VERSION, + uri: '/organizations/org-1/grants', + }).reply(200, { + 'sanity.organization.projects': [{grants: [{name: 'attach'}]}], + }) + + mockApi({ + apiVersion: CREATE_PROJECT_API_VERSION, + method: 'post', + uri: '/projects', + }).reply(200, {displayName: 'My New Project', projectId: 'test-project'}) + + mockApi({ + apiVersion: PROJECT_FEATURES_API_VERSION, + method: 'get', + uri: '/features', + }).reply(200, ['privateDataset']) + + const context = createTestContext() + const options: InitOptions = { + ...defaultOptions, + bare: true, + projectName: 'My New Project', + unattended: true, + } + + await initAction(options, context) + + const logCalls = vi.mocked(context.output.log).mock.calls.map((call) => call[0]) + const combined = logCalls.join('\n') + + expect(combined).toContain('test-project') + }) + + test('unattended --project-name with zero orgs auto-creates an organization', async () => { + mockValidateSession.mockResolvedValue({ + email: 'test@example.com', + id: 'user-123', + name: 'Test User', + provider: 'google', + }) + + mockApi({ + apiVersion: ORGANIZATIONS_API_VERSION, + uri: '/organizations', + }).reply(200, []) + + mockApi({ + apiVersion: ORGANIZATIONS_API_VERSION, + method: 'post', + uri: '/organizations', + }).reply(200, { + createdByUserId: 'user-123', + defaultRoleName: null, + features: [], + id: 'org-auto', + members: [], + name: 'Test User', + slug: 'test-user', + }) + + mockApi({ + apiVersion: CREATE_PROJECT_API_VERSION, + method: 'post', + uri: '/projects', + }).reply(200, {displayName: 'My New Project', projectId: 'test-project'}) + + mockApi({ + apiVersion: PROJECT_FEATURES_API_VERSION, + method: 'get', + uri: '/features', + }).reply(200, ['privateDataset']) + + const context = createTestContext() + const options: InitOptions = { + ...defaultOptions, + bare: true, + projectName: 'My New Project', + unattended: true, + } + + await initAction(options, context) + + const logCalls = vi.mocked(context.output.log).mock.calls.map((call) => call[0]) + const combined = logCalls.join('\n') + + expect(combined).toContain('test-project') + }) + + test('unattended --project-name with multiple orgs lists them and hints', async () => { + mockValidateSession.mockResolvedValue({ + email: 'test@example.com', + id: 'user-123', + name: 'Test User', + provider: 'google', + }) + + mockApi({ + apiVersion: ORGANIZATIONS_API_VERSION, + uri: '/organizations', + }).reply(200, [ + {id: 'org-a', name: 'Alpha Org', slug: 'alpha'}, + {id: 'org-b', name: 'Beta Org', slug: 'beta'}, + ]) + + mockApi({ + apiVersion: ORGANIZATIONS_API_VERSION, + uri: '/organizations/org-a/grants', + }).reply(200, { + 'sanity.organization.projects': [{grants: [{name: 'attach'}]}], + }) + + mockApi({ + apiVersion: ORGANIZATIONS_API_VERSION, + uri: '/organizations/org-b/grants', + }).reply(200, { + 'sanity.organization.projects': [{grants: [{name: 'attach'}]}], + }) + + const context = createTestContext() + const options: InitOptions = { + ...defaultOptions, + bare: true, + projectName: 'My New Project', + unattended: true, + } + + let caughtError: unknown + try { + await initAction(options, context) + } catch (error) { + caughtError = error + } + + expect(caughtError).toBeInstanceOf(InitError) + const initError = caughtError as InitError + expect(initError.message).toContain('Multiple organizations available') + expect(initError.message).toContain('org-a (Alpha Org)') + expect(initError.message).toContain('org-b (Beta Org)') + expect(initError.message).toContain('[Hint]') + expect(initError.message).toContain('sanity init --organization org-a') + expect(initError.exitCode).toBe(1) + }) + + test('unattended without --project/--project-name derives projectName from package.json name', async () => { + mockValidateSession.mockResolvedValue({ + email: 'test@example.com', + id: 'user-123', + name: 'Test User', + provider: 'google', + }) + + const tmpDir = mkdtempSync(path.join(tmpdir(), 'sanity-init-test-')) + writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({name: 'my-pkg-name', version: '1.0.0'}), + ) + + try { + mockApi({ + apiVersion: ORGANIZATIONS_API_VERSION, + uri: '/organizations', + }).reply(200, [{id: 'org-1', name: 'Only Org', slug: 'only-org'}]) + + mockApi({ + apiVersion: ORGANIZATIONS_API_VERSION, + uri: '/organizations/org-1/grants', + }).reply(200, { + 'sanity.organization.projects': [{grants: [{name: 'attach'}]}], + }) + + mockApi({ + apiVersion: CREATE_PROJECT_API_VERSION, + method: 'post', + uri: '/projects', + }).reply(200, (_uri, body: Record) => { + expect(body.displayName).toBe('my-pkg-name') + return {displayName: 'my-pkg-name', projectId: 'test-project'} + }) + + mockApi({ + apiVersion: PROJECT_FEATURES_API_VERSION, + method: 'get', + uri: '/features', + }).reply(200, ['privateDataset']) + + const context = createTestContext(tmpDir) + const options: InitOptions = { + ...defaultOptions, + bare: true, + unattended: true, + } + + await initAction(options, context) + } finally { + rmSync(tmpDir, {force: true, recursive: true}) + } + }) + + test('unattended without --project/--project-name and no package.json derives projectName from basename(cwd)', async () => { + mockValidateSession.mockResolvedValue({ + email: 'test@example.com', + id: 'user-123', + name: 'Test User', + provider: 'google', + }) + + const tmpDir = mkdtempSync(path.join(tmpdir(), 'my-folder-name-')) + const expectedName = path.basename(tmpDir) + + try { + mockApi({ + apiVersion: ORGANIZATIONS_API_VERSION, + uri: '/organizations', + }).reply(200, [{id: 'org-1', name: 'Only Org', slug: 'only-org'}]) + + mockApi({ + apiVersion: ORGANIZATIONS_API_VERSION, + uri: '/organizations/org-1/grants', + }).reply(200, { + 'sanity.organization.projects': [{grants: [{name: 'attach'}]}], + }) + + mockApi({ + apiVersion: CREATE_PROJECT_API_VERSION, + method: 'post', + uri: '/projects', + }).reply(200, (_uri, body: Record) => { + expect(body.displayName).toBe(expectedName) + return {displayName: expectedName, projectId: 'test-project'} + }) + + mockApi({ + apiVersion: PROJECT_FEATURES_API_VERSION, + method: 'get', + uri: '/features', + }).reply(200, ['privateDataset']) + + const context = createTestContext(tmpDir) + const options: InitOptions = { + ...defaultOptions, + bare: true, + unattended: true, + } + + await initAction(options, context) + } finally { + rmSync(tmpDir, {force: true, recursive: true}) + } + }) }) diff --git a/packages/@sanity/cli/src/actions/init/initAction.ts b/packages/@sanity/cli/src/actions/init/initAction.ts index a1fd41b74..9d7113c76 100644 --- a/packages/@sanity/cli/src/actions/init/initAction.ts +++ b/packages/@sanity/cli/src/actions/init/initAction.ts @@ -1,3 +1,5 @@ +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' @@ -10,6 +12,7 @@ import {promptForConfigFiles} from '../../prompts/init/nextjs.js' import {getCliUser} from '../../services/user.js' import {CLIInitStepCompleted, type InitStepResult} from '../../telemetry/init.telemetry.js' import {detectFrameworkRecord} from '../../util/detectFramework.js' +import {formatHint} from '../../util/formatHint.js' import {getProjectDefaults} from '../../util/getProjectDefaults.js' import {validateSession} from '../auth/ensureAuthenticated.js' import {getProviderName} from '../auth/getProviderName.js' @@ -83,8 +86,28 @@ export async function initAction(options: InitOptions, context: InitContext): Pr const isAppTemplate = options.template ? determineAppTemplate(options.template) : false + let effectiveProjectName = options.projectName + if (options.unattended && !isAppTemplate && !options.project && !effectiveProjectName) { + const derived = await deriveProjectName(workDir) + if (derived) { + debug('Deriving --project-name from %s: %s', derived.source, derived.name) + effectiveProjectName = derived.name + } + } + + if (options.bare && options.outputPath) { + throw new InitError( + '--bare cannot be used with --output-path. Use --bare to create the project only, then scaffold the studio separately.' + + formatHint( + 'sanity init --bare --project-name "my-project" --dataset production -y', + 'sanity init --project --output-path ./studio -y', + ), + 1, + ) + } + if (options.unattended) { - checkFlagsInUnattendedMode(options, {isAppTemplate, isNextJs}) + checkFlagsInUnattendedMode(options, {effectiveProjectName, isAppTemplate, isNextJs}) } trace.start() @@ -117,13 +140,14 @@ export async function initAction(options: InitOptions, context: InitContext): Pr } let newProject: string | undefined - if (options.projectName) { + if (effectiveProjectName) { newProject = await createProjectFromName({ coupon: options.coupon, - createProjectName: options.projectName, + createProjectName: effectiveProjectName, dataset: options.dataset, organization: options.organization, planId, + unattended: options.unattended, user, visibility: options.visibility, }) @@ -278,25 +302,28 @@ export async function initAction(options: InitOptions, context: InitContext): Pr function checkFlagsInUnattendedMode( options: InitOptions, - {isAppTemplate, isNextJs}: {isAppTemplate: boolean; isNextJs: boolean}, + { + effectiveProjectName, + isAppTemplate, + isNextJs, + }: {effectiveProjectName: string | undefined; isAppTemplate: boolean; isNextJs: boolean}, ): void { debug('Unattended mode, validating required options') - if (options.projectName && !options.organization) { - throw new InitError('`--project-name` requires `--organization ` in unattended mode', 1) - } - if (isAppTemplate) { if (!options.outputPath) { throw new InitError('`--output-path` must be specified in unattended mode', 1) } - const hasProjectFlag = Boolean(options.project || options.projectName) + const hasProjectFlag = Boolean(options.project || effectiveProjectName) if (!hasProjectFlag && !options.organization) { throw new InitError( 'The --organization flag is required for app templates in unattended mode. ' + - 'Use --organization , or pass --project / --project-name .', + 'Use --organization , or pass --project / --project-name .' + + formatHint( + 'sanity init --organization --template