diff --git a/src/commands/env/env-clone.ts b/src/commands/env/env-clone.ts index 50b661aa141..c1d5ccc0997 100644 --- a/src/commands/env/env-clone.ts +++ b/src/commands/env/env-clone.ts @@ -3,6 +3,7 @@ import { OptionValues } from 'commander' import { chalk, log, logAndThrowError } from '../../utils/command-helpers.js' import { promptEnvCloneOverwrite } from '../../utils/prompts/env-clone-prompt.js' import BaseCommand from '../base-command.js' +import { failNotLinked } from './utils.js' // @ts-expect-error TS(7006) FIXME: Parameter 'api' implicitly has an 'any' type. const safeGetSite = async (api, siteId) => { @@ -60,10 +61,10 @@ export const envClone = async (options: OptionValues, command: BaseCommand) => { const { force } = options if (!site.id && !options.from) { - log( + return failNotLinked( + options, 'Please include the source project ID as the `--from` option, or run `netlify link` to link this folder to a Netlify project', ) - return false } const sourceId = options.from || site.id diff --git a/src/commands/env/env-get.ts b/src/commands/env/env-get.ts index 4ca9e8e179b..9afb02a2e7f 100644 --- a/src/commands/env/env-get.ts +++ b/src/commands/env/env-get.ts @@ -3,16 +3,15 @@ import { OptionValues } from 'commander' import { chalk, log, logJson } from '../../utils/command-helpers.js' import { SUPPORTED_CONTEXTS, getEnvelopeEnv } from '../../utils/env/index.js' import BaseCommand from '../base-command.js' -import { getSiteInfo } from './utils.js' +import { failNotLinked, getEnvSiteId, getSiteInfo } from './utils.js' export const envGet = async (name: string, options: OptionValues, command: BaseCommand) => { const { context, scope } = options - const { api, cachedConfig, site } = command.netlify - const siteId = site.id + const { api, cachedConfig } = command.netlify + const siteId = getEnvSiteId(options, command) if (!siteId) { - log('No project id found, please run inside a project folder or `netlify link`') - return false + return failNotLinked(options) } const siteInfo = await getSiteInfo(api, siteId, cachedConfig) diff --git a/src/commands/env/env-import.ts b/src/commands/env/env-import.ts index 1a14da1d555..7c190a472d4 100644 --- a/src/commands/env/env-import.ts +++ b/src/commands/env/env-import.ts @@ -7,7 +7,7 @@ import dotenv from 'dotenv' import { exit, log, logJson } from '../../utils/command-helpers.js' import { translateFromEnvelopeToMongo, translateFromMongoToEnvelope } from '../../utils/env/index.js' import BaseCommand from '../base-command.js' -import { getSiteInfo } from './utils.js' +import { failNotLinked, getEnvSiteId, getSiteInfo } from './utils.js' /** * Saves the imported env in the Envelope service @@ -51,12 +51,11 @@ const importDotEnv = async ({ api, importedEnv, options, siteInfo }) => { } export const envImport = async (fileName: string, options: OptionValues, command: BaseCommand) => { - const { api, cachedConfig, site } = command.netlify - const siteId = site.id + const { api, cachedConfig } = command.netlify + const siteId = getEnvSiteId(options, command) if (!siteId) { - log('No project id found, please run inside a project folder or `netlify link`') - return false + return failNotLinked(options) } const siteInfo = await getSiteInfo(api, siteId, cachedConfig) diff --git a/src/commands/env/env-list.ts b/src/commands/env/env-list.ts index b96412b36f7..1390beba397 100644 --- a/src/commands/env/env-list.ts +++ b/src/commands/env/env-list.ts @@ -9,7 +9,7 @@ import { chalk, log, logJson } from '../../utils/command-helpers.js' import { SUPPORTED_CONTEXTS, getEnvelopeEnv, getHumanReadableScopes } from '../../utils/env/index.js' import type BaseCommand from '../base-command.js' import { EnvironmentVariables } from '../../utils/types.js' -import { getSiteInfo } from './utils.js' +import { failNotLinked, getEnvSiteId, getSiteInfo } from './utils.js' const MASK_LENGTH = 50 const MASK = '*'.repeat(MASK_LENGTH) @@ -43,12 +43,11 @@ const getTable = ({ export const envList = async (options: OptionValues, command: BaseCommand) => { const { context, scope } = options - const { api, cachedConfig, site } = command.netlify - const siteId = site.id + const { api, cachedConfig } = command.netlify + const siteId = getEnvSiteId(options, command) if (!siteId) { - log('No project id found, please run inside a project folder or `netlify link`') - return false + return failNotLinked(options) } const siteInfo = await getSiteInfo(api, siteId, cachedConfig) diff --git a/src/commands/env/env-set.ts b/src/commands/env/env-set.ts index d1eac1b1e84..b105d969752 100644 --- a/src/commands/env/env-set.ts +++ b/src/commands/env/env-set.ts @@ -4,7 +4,7 @@ import { chalk, logAndThrowError, log, logJson } from '../../utils/command-helpe import { SUPPORTED_CONTEXTS, ALL_ENVELOPE_SCOPES, translateFromEnvelopeToMongo } from '../../utils/env/index.js' import { promptOverwriteEnvVariable } from '../../utils/prompts/env-set-prompts.js' import BaseCommand from '../base-command.js' -import { getSiteInfo } from './utils.js' +import { failNotLinked, getEnvSiteId, getSiteInfo } from './utils.js' /** * Updates the env for a site configured with Envelope with a new key/value pair @@ -110,11 +110,10 @@ const setInEnvelope = async ({ api, context, force, key, scope, secret, siteInfo export const envSet = async (key: string, value: string, options: OptionValues, command: BaseCommand) => { const { context, force, scope, secret } = options - const { api, cachedConfig, site } = command.netlify - const siteId = site.id + const { api, cachedConfig } = command.netlify + const siteId = getEnvSiteId(options, command) if (!siteId) { - log('No project id found, please run inside a project folder or `netlify link`') - return false + return failNotLinked(options) } const siteInfo = await getSiteInfo(api, siteId, cachedConfig) diff --git a/src/commands/env/env-unset.ts b/src/commands/env/env-unset.ts index 3e3baebf8e5..325d30c1f99 100644 --- a/src/commands/env/env-unset.ts +++ b/src/commands/env/env-unset.ts @@ -4,7 +4,7 @@ import { chalk, log, logJson } from '../../utils/command-helpers.js' import { SUPPORTED_CONTEXTS, translateFromEnvelopeToMongo } from '../../utils/env/index.js' import { promptOverwriteEnvVariable } from '../../utils/prompts/env-unset-prompts.js' import BaseCommand from '../base-command.js' -import { getSiteInfo } from './utils.js' +import { failNotLinked, getEnvSiteId, getSiteInfo } from './utils.js' /** * Deletes a given key from the env of a site configured with Envelope * @returns {Promise} @@ -70,12 +70,11 @@ const unsetInEnvelope = async ({ api, context, force, key, siteInfo }) => { export const envUnset = async (key: string, options: OptionValues, command: BaseCommand) => { const { context, force } = options - const { api, cachedConfig, site } = command.netlify - const siteId = site.id + const { api, cachedConfig } = command.netlify + const siteId = getEnvSiteId(options, command) if (!siteId) { - log('No project id found, please run inside a project folder or `netlify link`') - return false + return failNotLinked(options) } const siteInfo = await getSiteInfo(api, siteId, cachedConfig) diff --git a/src/commands/env/utils.ts b/src/commands/env/utils.ts index bae690a5581..6512afbe9e4 100644 --- a/src/commands/env/utils.ts +++ b/src/commands/env/utils.ts @@ -1,7 +1,10 @@ import type { NetlifyAPI } from '@netlify/api' +import type { OptionValues } from 'commander' import type { CachedConfig } from '../../lib/build.js' +import { exit, logJson } from '../../utils/command-helpers.js' import type { SiteInfo } from '../../utils/types.js' +import type BaseCommand from '../base-command.js' export const getSiteInfo = async (api: NetlifyAPI, siteId: string, cachedConfig: CachedConfig): Promise => { const { siteInfo: cachedSiteInfo } = cachedConfig @@ -10,3 +13,18 @@ export const getSiteInfo = async (api: NetlifyAPI, siteId: string, cachedConfig: } return cachedSiteInfo } + +export const getEnvSiteId = (options: OptionValues, command: BaseCommand): string | undefined => + options.site ? command.netlify.siteInfo.id : command.netlify.site.id + +export const failNotLinked = ( + options: OptionValues, + message = 'No project id found, please run inside a project folder or `netlify link`', +): never => { + if (options.json) { + logJson({ error: { code: 'NOT_LINKED', message, fix: 'netlify link' } }) + } else { + process.stderr.write(`${message}\n`) + } + return exit(1) +} diff --git a/tests/integration/commands/env/__snapshots__/env.test.ts.snap b/tests/integration/commands/env/__snapshots__/env.test.ts.snap index 41231145f9b..280e6535901 100644 --- a/tests/integration/commands/env/__snapshots__/env.test.ts.snap +++ b/tests/integration/commands/env/__snapshots__/env.test.ts.snap @@ -1,7 +1,5 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`commands/env > env:clone > should exit if the folder is not linked to a project, and --from is not provided 1`] = `"Please include the source project ID as the \`--from\` option, or run \`netlify link\` to link this folder to a Netlify project"`; - exports[`commands/env > env:clone > should return success message 1`] = ` "Successfully cloned environment variables from site-name to site-name-a Changes will require a redeploy to take effect on any deployed versions of your project." diff --git a/tests/integration/commands/env/env.test.ts b/tests/integration/commands/env/env.test.ts index c8bd08f3ef0..aaba3724197 100644 --- a/tests/integration/commands/env/env.test.ts +++ b/tests/integration/commands/env/env.test.ts @@ -442,19 +442,22 @@ describe('commands/env', () => { }) }) - test('should exit if the folder is not linked to a project, and --from is not provided', async (t) => { + test('should exit non-zero if the folder is not linked to a project, and --from is not provided', async (t) => { await withSiteBuilder(t, async (builder) => { await builder.build() - const cliResponse = (await callCli(['env:clone', '--to', 'site_id_a', '--force'], { + const error = (await callCli(['env:clone', '--to', 'site_id_a', '--force'], { cwd: builder.directory, extendEnv: false, env: { PATH: process.env.PATH, }, - })) as string + }).catch((error_: unknown) => error_)) as { exitCode: number; message: string } - t.expect(normalize(cliResponse)).toMatchSnapshot() + t.expect(error.exitCode).toBe(1) + t.expect(error.message).toContain( + 'Please include the source project ID as the `--from` option, or run `netlify link`', + ) }) }) diff --git a/tests/unit/commands/env/env-get.test.ts b/tests/unit/commands/env/env-get.test.ts new file mode 100644 index 00000000000..fd69372235b --- /dev/null +++ b/tests/unit/commands/env/env-get.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, test, vi, beforeEach, afterEach, type MockInstance } from 'vitest' + +const { mockGetEnvelopeEnv, jsonMessages, logMessages } = vi.hoisted(() => ({ + mockGetEnvelopeEnv: vi.fn(), + jsonMessages: [] as unknown[], + logMessages: [] as string[], +})) + +vi.mock('../../../../src/utils/command-helpers.js', async () => ({ + ...(await vi.importActual('../../../../src/utils/command-helpers.js')), + log: (...args: string[]) => { + logMessages.push(args.join(' ')) + }, + logJson: (message: unknown) => { + jsonMessages.push(message) + }, + exit: (code = 0): never => { + throw new Error(`process.exit(${String(code)})`) + }, +})) + +vi.mock('../../../../src/utils/env/index.js', async () => ({ + ...(await vi.importActual('../../../../src/utils/env/index.js')), + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + getEnvelopeEnv: (...args: unknown[]) => mockGetEnvelopeEnv(...args), +})) + +import { envGet } from '../../../../src/commands/env/env-get.js' +import type BaseCommand from '../../../../src/commands/base-command.js' + +const createMockCommand = ({ linkedSiteId, flagSiteId }: { linkedSiteId?: string; flagSiteId?: string } = {}) => { + const api = { getSite: vi.fn().mockResolvedValue({ id: flagSiteId, name: 'flag-site' }) } + const command = { + netlify: { + api, + cachedConfig: { env: {}, siteInfo: { id: linkedSiteId } }, + site: { id: linkedSiteId }, + siteInfo: { id: flagSiteId }, + }, + } as unknown as BaseCommand + return { api, command } +} + +describe('envGet', () => { + let stderrSpy: MockInstance + + beforeEach(() => { + jsonMessages.length = 0 + logMessages.length = 0 + vi.clearAllMocks() + mockGetEnvelopeEnv.mockResolvedValue({ FOO: { value: 'bar' } }) + stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + }) + + afterEach(() => { + stderrSpy.mockRestore() + }) + + test('prints a NOT_LINKED JSON envelope and exits non-zero when unlinked with --json', async () => { + const { command } = createMockCommand() + + await expect(envGet('FOO', { json: true }, command)).rejects.toThrowError('process.exit(1)') + + expect(jsonMessages).toHaveLength(1) + expect(jsonMessages[0]).toMatchObject({ error: { code: 'NOT_LINKED', fix: 'netlify link' } }) + expect(mockGetEnvelopeEnv).not.toHaveBeenCalled() + }) + + test('prints prose to stderr and exits non-zero when unlinked without --json', async () => { + const { command } = createMockCommand() + + await expect(envGet('FOO', {}, command)).rejects.toThrowError('process.exit(1)') + + expect(stderrSpy).toHaveBeenCalledWith( + 'No project id found, please run inside a project folder or `netlify link`\n', + ) + expect(jsonMessages).toHaveLength(0) + }) + + test('returns the variable for the linked site without --site', async () => { + const { command } = createMockCommand({ linkedSiteId: 'linked-id' }) + + await envGet('FOO', { json: true, context: 'dev', scope: 'any' }, command) + + expect(jsonMessages).toEqual([{ FOO: 'bar' }]) + }) + + test('honors --site by using the resolved siteInfo instead of the linked state', async () => { + const { api, command } = createMockCommand({ flagSiteId: 'flag-site-id' }) + + await envGet('FOO', { json: true, context: 'dev', scope: 'any', site: 'flag-site-id' }, command) + + expect(api.getSite).toHaveBeenCalledWith({ siteId: 'flag-site-id' }) + const [envelopeArgs] = mockGetEnvelopeEnv.mock.calls[0] as [{ siteInfo: { id: string } }] + expect(envelopeArgs.siteInfo.id).toBe('flag-site-id') + expect(jsonMessages).toEqual([{ FOO: 'bar' }]) + }) + + test('prefers --site over the linked site id', async () => { + const { api, command } = createMockCommand({ linkedSiteId: 'linked-id', flagSiteId: 'flag-site-id' }) + + await envGet('FOO', { json: true, context: 'dev', scope: 'any', site: 'flag-site-id' }, command) + + expect(api.getSite).toHaveBeenCalledWith({ siteId: 'flag-site-id' }) + const [envelopeArgs] = mockGetEnvelopeEnv.mock.calls[0] as [{ siteInfo: { id: string } }] + expect(envelopeArgs.siteInfo.id).toBe('flag-site-id') + }) +}) diff --git a/tests/unit/commands/env/utils.test.ts b/tests/unit/commands/env/utils.test.ts new file mode 100644 index 00000000000..87b3d8d82bb --- /dev/null +++ b/tests/unit/commands/env/utils.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test, vi, beforeEach, afterEach, type MockInstance } from 'vitest' + +const { jsonMessages } = vi.hoisted(() => ({ jsonMessages: [] as unknown[] })) + +vi.mock('../../../../src/utils/command-helpers.js', async () => ({ + ...(await vi.importActual('../../../../src/utils/command-helpers.js')), + logJson: (message: unknown) => { + jsonMessages.push(message) + }, + exit: (code = 0): never => { + throw new Error(`process.exit(${String(code)})`) + }, +})) + +import { failNotLinked, getEnvSiteId } from '../../../../src/commands/env/utils.js' +import type BaseCommand from '../../../../src/commands/base-command.js' + +const createMockCommand = ({ linkedSiteId, flagSiteId }: { linkedSiteId?: string; flagSiteId?: string }) => + ({ + netlify: { + site: { id: linkedSiteId }, + siteInfo: { id: flagSiteId }, + }, + } as unknown as BaseCommand) + +describe('getEnvSiteId', () => { + test('returns the linked site id when --site is not passed', () => { + const command = createMockCommand({ linkedSiteId: 'linked-id' }) + + expect(getEnvSiteId({}, command)).toBe('linked-id') + }) + + test('returns the resolved siteInfo id when --site is passed', () => { + const command = createMockCommand({ linkedSiteId: 'linked-id', flagSiteId: 'flag-id' }) + + expect(getEnvSiteId({ site: 'my-project' }, command)).toBe('flag-id') + }) + + test('returns undefined when unlinked and --site is not passed', () => { + const command = createMockCommand({}) + + expect(getEnvSiteId({}, command)).toBeUndefined() + }) +}) + +describe('failNotLinked', () => { + let stderrSpy: MockInstance + + beforeEach(() => { + jsonMessages.length = 0 + stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + }) + + afterEach(() => { + stderrSpy.mockRestore() + }) + + test('prints a NOT_LINKED JSON envelope to stdout and exits non-zero with --json', () => { + expect(() => failNotLinked({ json: true })).toThrowError('process.exit(1)') + + expect(jsonMessages).toHaveLength(1) + expect(jsonMessages[0]).toEqual({ + error: { + code: 'NOT_LINKED', + message: 'No project id found, please run inside a project folder or `netlify link`', + fix: 'netlify link', + }, + }) + expect(stderrSpy).not.toHaveBeenCalled() + }) + + test('prints prose to stderr and exits non-zero without --json', () => { + expect(() => failNotLinked({})).toThrowError('process.exit(1)') + + expect(jsonMessages).toHaveLength(0) + expect(stderrSpy).toHaveBeenCalledWith( + 'No project id found, please run inside a project folder or `netlify link`\n', + ) + }) + + test('uses the provided message override', () => { + expect(() => failNotLinked({}, 'custom message')).toThrowError('process.exit(1)') + + expect(stderrSpy).toHaveBeenCalledWith('custom message\n') + }) +})