From 126f1f6c952c70f021846633e9d51846500fc0ca Mon Sep 17 00:00:00 2001 From: DavidWells Date: Mon, 6 Jul 2026 11:00:47 -0700 Subject: [PATCH] feat(status): --json always emits a JSON object for every auth/link state status --json emitted zero bytes of stdout when logged out or unlinked. Every state now produces a JSON object with loggedIn/linked booleans and a stable error code (NOT_LOGGED_IN, NOT_LINKED) plus suggested fix. Success payload gains additive loggedIn/linked/error fields. --- src/commands/status/status.ts | 46 ++++++ tests/unit/commands/status/status.test.ts | 186 ++++++++++++++++++++++ 2 files changed, 232 insertions(+) create mode 100644 tests/unit/commands/status/status.test.ts diff --git a/src/commands/status/status.ts b/src/commands/status/status.ts index ed44723aaad..c136171e207 100644 --- a/src/commands/status/status.ts +++ b/src/commands/status/status.ts @@ -15,12 +15,31 @@ import { import { isInteractive } from '../../utils/scripted-commands.js' import type BaseCommand from '../base-command.js' +// TODO: centralize these in a shared error-code dictionary alongside the exit codes. +export const STATUS_ERROR_CODES = { + NOT_LOGGED_IN: 'NOT_LOGGED_IN', + NOT_LINKED: 'NOT_LINKED', +} as const + export const status = async (options: OptionValues, command: BaseCommand) => { const { accounts, api, globalConfig, site, siteInfo } = command.netlify const currentUserId = globalConfig.get('userId') as string | undefined const [accessToken] = await getToken() if (!accessToken) { + if (options.json) { + logJson({ + loggedIn: false, + linked: false, + account: null, + siteData: null, + error: { + code: STATUS_ERROR_CODES.NOT_LOGGED_IN, + fix: 'netlify login or NETLIFY_AUTH_TOKEN', + }, + }) + return exit(1) + } log(`Not logged in. Please log in to see project status.`) log() if (!isInteractive()) { @@ -43,6 +62,18 @@ export const status = async (options: OptionValues, command: BaseCommand) => { user = await api.getCurrentUser() } catch (error_) { if ((error_ as APIError).status === 401) { + if (options.json) { + logJson({ + loggedIn: false, + linked: false, + account: null, + siteData: null, + error: { + code: STATUS_ERROR_CODES.NOT_LOGGED_IN, + fix: 'netlify login or NETLIFY_AUTH_TOKEN', + }, + }) + } return logAndThrowError( 'Your session has expired. Please try to re-authenticate by running `netlify logout` and `netlify login`.', ) @@ -70,6 +101,18 @@ export const status = async (options: OptionValues, command: BaseCommand) => { log(prettyjson.render(cleanAccountData)) if (!siteId) { + if (options.json) { + logJson({ + loggedIn: true, + linked: false, + account: cleanAccountData, + siteData: null, + error: { + code: STATUS_ERROR_CODES.NOT_LINKED, + fix: 'netlify link', + }, + }) + } warn('Did you run `netlify link` yet?') return logAndThrowError(`You don't appear to be in a folder that is linked to a project`) } @@ -85,6 +128,9 @@ export const status = async (options: OptionValues, command: BaseCommand) => { 'site-url': siteInfo.ssl_url || siteInfo.url, 'site-id': siteInfo.id, }, + loggedIn: true, + linked: true, + error: null, }) } diff --git a/tests/unit/commands/status/status.test.ts b/tests/unit/commands/status/status.test.ts new file mode 100644 index 00000000000..9f9b9d0fe2b --- /dev/null +++ b/tests/unit/commands/status/status.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, test, vi, beforeEach } from 'vitest' + +const { mockGetToken, mockGetCurrentUser, logMessages, jsonMessages, exitCalls } = vi.hoisted(() => { + const mockGetToken = vi.fn() + const mockGetCurrentUser = vi.fn() + const logMessages: string[] = [] + const jsonMessages: unknown[] = [] + const exitCalls: number[] = [] + return { mockGetToken, mockGetCurrentUser, logMessages, jsonMessages, exitCalls } +}) + +vi.mock('../../../../src/utils/command-helpers.js', async () => ({ + ...(await vi.importActual('../../../../src/utils/command-helpers.js')), + getToken: (...args: unknown[]) => mockGetToken(...args) as unknown, + log: (...args: string[]) => { + logMessages.push(args.join(' ')) + }, + logJson: (message: unknown) => { + jsonMessages.push(message) + }, + warn: (message: string) => { + logMessages.push(message) + }, + logAndThrowError: (message: unknown): never => { + throw message instanceof Error ? message : new Error(String(message)) + }, + exit: (code = 0): never => { + exitCalls.push(code) + throw new Error(`exit(${String(code)})`) + }, +})) + +import { status, STATUS_ERROR_CODES } from '../../../../src/commands/status/status.js' + +function createMockCommand(overrides: { siteId?: string } = {}) { + const { siteId } = overrides + + return { + netlify: { + accounts: [{ name: 'My Team' }], + api: { + getCurrentUser: (...args: unknown[]) => mockGetCurrentUser(...args) as unknown, + }, + globalConfig: { + get: vi.fn().mockReturnValue(undefined), + }, + site: { id: siteId, configPath: '/project/netlify.toml' }, + siteInfo: { + id: siteId, + name: 'my-site', + admin_url: 'https://app.netlify.com/sites/my-site', + ssl_url: 'https://my-site.netlify.app', + url: 'http://my-site.netlify.app', + }, + }, + } as unknown as Parameters[1] +} + +describe('status', () => { + beforeEach(() => { + logMessages.length = 0 + jsonMessages.length = 0 + exitCalls.length = 0 + vi.clearAllMocks() + mockGetToken.mockResolvedValue(['fake-token', 'config']) + mockGetCurrentUser.mockResolvedValue({ full_name: 'Test User', email: 'test@example.com' }) + }) + + describe('not logged in', () => { + beforeEach(() => { + mockGetToken.mockResolvedValue([null, 'not found']) + }) + + test('with --json emits a NOT_LOGGED_IN error envelope and exits non-zero', async () => { + await expect(status({ json: true }, createMockCommand())).rejects.toThrow('exit(1)') + + expect(jsonMessages).toHaveLength(1) + expect(jsonMessages[0]).toEqual({ + loggedIn: false, + linked: false, + account: null, + siteData: null, + error: { + code: STATUS_ERROR_CODES.NOT_LOGGED_IN, + fix: 'netlify login or NETLIFY_AUTH_TOKEN', + }, + }) + expect(exitCalls).toEqual([1]) + }) + + test('without --json keeps existing behavior: human message and exit 0', async () => { + await expect(status({}, createMockCommand())).rejects.toThrow('exit(0)') + + expect(jsonMessages).toHaveLength(0) + expect(exitCalls).toEqual([0]) + expect(logMessages.join('\n')).toContain('Not logged in') + }) + }) + + describe('logged in but not linked', () => { + test('with --json emits a NOT_LINKED error envelope including account data', async () => { + await expect(status({ json: true }, createMockCommand())).rejects.toThrow( + "You don't appear to be in a folder that is linked to a project", + ) + + expect(jsonMessages).toHaveLength(1) + expect(jsonMessages[0]).toEqual({ + loggedIn: true, + linked: false, + account: { + Name: 'Test User', + Email: 'test@example.com', + Teams: ['My Team'], + }, + siteData: null, + error: { + code: STATUS_ERROR_CODES.NOT_LINKED, + fix: 'netlify link', + }, + }) + }) + + test('without --json keeps existing behavior: warns and throws without JSON output', async () => { + await expect(status({}, createMockCommand())).rejects.toThrow( + "You don't appear to be in a folder that is linked to a project", + ) + + expect(jsonMessages).toHaveLength(0) + expect(logMessages.join('\n')).toContain('Did you run `netlify link` yet?') + }) + }) + + describe('logged in and linked', () => { + test('with --json keeps existing keys and adds loggedIn, linked, and error', async () => { + await status({ json: true }, createMockCommand({ siteId: 'site-123' })) + + expect(jsonMessages).toHaveLength(1) + expect(jsonMessages[0]).toEqual({ + account: { + Name: 'Test User', + Email: 'test@example.com', + Teams: ['My Team'], + }, + siteData: { + 'site-name': 'my-site', + 'config-path': '/project/netlify.toml', + 'admin-url': 'https://app.netlify.com/sites/my-site', + 'site-url': 'https://my-site.netlify.app', + 'site-id': 'site-123', + }, + loggedIn: true, + linked: true, + error: null, + }) + expect(exitCalls).toHaveLength(0) + }) + + test('without --json does not emit JSON', async () => { + await status({}, createMockCommand({ siteId: 'site-123' })) + + expect(jsonMessages).toHaveLength(0) + }) + }) + + describe('expired session', () => { + beforeEach(() => { + mockGetCurrentUser.mockRejectedValue(Object.assign(new Error('Unauthorized'), { status: 401 })) + }) + + test('with --json emits a NOT_LOGGED_IN error envelope before throwing', async () => { + await expect(status({ json: true }, createMockCommand())).rejects.toThrow('Your session has expired') + + expect(jsonMessages).toHaveLength(1) + expect(jsonMessages[0]).toMatchObject({ + loggedIn: false, + error: { code: STATUS_ERROR_CODES.NOT_LOGGED_IN }, + }) + }) + + test('without --json keeps existing behavior: throws without JSON output', async () => { + await expect(status({}, createMockCommand())).rejects.toThrow('Your session has expired') + + expect(jsonMessages).toHaveLength(0) + }) + }) +})