diff --git a/package.json b/package.json index d06de9e..a275d79 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@patchstack/connect", - "version": "0.2.6", + "version": "0.2.7", "description": "Patchstack connector for JavaScript applications. Scans your lockfile and reports installed packages to Patchstack for vulnerability monitoring.", "keywords": [ "patchstack", diff --git a/src/cli.ts b/src/cli.ts index a4db644..c5526a8 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -33,6 +33,7 @@ Environment: PATCHSTACK_SITE_UUID Site UUID PATCHSTACK_ENDPOINT API endpoint (default: https://api.patchstack.com/monitor/pulse/manifest) PATCHSTACK_TIMEOUT_MS Request timeout in ms (default: 30000) + PATCHSTACK_ENVIRONMENT Manifest environment: production | sandbox (default: production) Precedence: CLI flag > environment variable > .patchstackrc.json. @@ -121,6 +122,7 @@ async function runScan(args: ParsedArgs): Promise { console.log( `Found ${payload.packages.length} unique package versions across ${stats.uniqueNames} package names in ${manifest.ecosystem} lockfile.`, ); + console.log(`Reporting under the ${config.environment} environment.`); if (stats.duplicateNames.length > 0) { console.log(`${stats.duplicateNames.length} package(s) appear at multiple versions:`); if (stats.duplicateNames.length <= 10) { @@ -184,9 +186,10 @@ async function runStatus(args: ParsedArgs): Promise { cliSiteUuid: getStringFlag(args.flags, 'site-uuid'), cliEndpoint: getStringFlag(args.flags, 'endpoint'), }); - console.log(`Site UUID: ${config.siteUuid ?? '(none yet — the next `scan` will provision one)'}`); - console.log(`Endpoint: ${config.endpoint}`); - console.log(`Timeout: ${config.timeoutMs}ms`); + console.log(`Site UUID: ${config.siteUuid ?? '(none yet — the next `scan` will provision one)'}`); + console.log(`Endpoint: ${config.endpoint}`); + console.log(`Timeout: ${config.timeoutMs}ms`); + console.log(`Environment: ${config.environment}`); if (config.siteUuid !== null) { console.log(`Claim URL: ${buildClaimUrl(config.endpoint, config.siteUuid)}`); } diff --git a/src/client.ts b/src/client.ts index 61a3da8..9774127 100644 --- a/src/client.ts +++ b/src/client.ts @@ -39,7 +39,7 @@ export async function postManifest( Accept: 'application/json', 'User-Agent': '@patchstack/connect', }, - body: JSON.stringify(payload), + body: JSON.stringify({ ...payload, environment: config.environment }), signal: AbortSignal.timeout(timeoutMs), }); } catch (cause) { diff --git a/src/config.ts b/src/config.ts index 58ee699..8172d80 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,14 +1,17 @@ import { readFile, writeFile } from 'node:fs/promises'; import path from 'node:path'; -import { PatchstackError, type Config } from './types.js'; +import { PatchstackError, type Config, type Environment } from './types.js'; import { DEFAULT_ENDPOINT, DEFAULT_TIMEOUT_MS } from './client.js'; const CONFIG_FILENAME = '.patchstackrc.json'; +export const DEFAULT_ENVIRONMENT: Environment = 'production'; + interface ConfigFile { siteUuid?: string; endpoint?: string; timeoutMs?: number; + environment?: string; } export interface ResolveConfigOptions { @@ -42,6 +45,15 @@ export async function resolveConfig(options: ResolveConfigOptions): Promise 0 && !isUuid(siteUuid)) { throw new PatchstackError( `Site UUID "${siteUuid}" does not look like a valid UUID.`, @@ -60,6 +72,7 @@ export async function resolveConfig(options: ResolveConfigOptions): Promise 0 ? environmentRaw : undefined, }; } function isUuid(value: string): boolean { return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value); } + +function isEnvironment(value: string): value is Environment { + return value === 'production' || value === 'sandbox'; +} diff --git a/src/types.ts b/src/types.ts index f92524b..a6b8cbc 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,13 @@ export type Ecosystem = 'npm' | 'composer'; +/** + * Which environment a manifest was captured in. The connector reports from real + * builds (prebuild scan / build hooks), so it defaults to 'production'. Override + * with PATCHSTACK_ENVIRONMENT=sandbox (or "environment" in .patchstackrc.json) + * for test manifests. + */ +export type Environment = 'production' | 'sandbox'; + export interface PackageEntry { name: string; version: string; @@ -21,6 +29,8 @@ export interface Config { siteUuid: string | null; endpoint: string; timeoutMs: number; + /** Environment to report the manifest under. Defaults to 'production'. */ + environment: Environment; } export interface StoreManifestResponse { diff --git a/tests/client.test.ts b/tests/client.test.ts index d31e8b2..7770fb5 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -77,13 +77,30 @@ describe('postManifest', () => { vi.stubGlobal('fetch', fetchMock); const result = await postManifest( - { siteUuid: 'uuid', endpoint: 'https://example.com', timeoutMs: 30_000 }, + { siteUuid: 'uuid', endpoint: 'https://example.com', timeoutMs: 30_000, environment: 'production' }, { ecosystem: 'npm', packages: [{ name: 'lodash', version: '4.17.21' }] }, ); expect(result.stored).toBe(true); expect(result.manifest_id).toBe(1); }); + it('sends the configured environment in the request body', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ stored: true }), { status: 200 }), + ); + vi.stubGlobal('fetch', fetchMock); + + await postManifest( + { siteUuid: 'uuid', endpoint: 'https://example.com', timeoutMs: 30_000, environment: 'sandbox' }, + { ecosystem: 'npm', packages: [{ name: 'lodash', version: '4.17.21' }] }, + ); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(init.body as string) as { environment: string; ecosystem: string }; + expect(body.environment).toBe('sandbox'); + expect(body.ecosystem).toBe('npm'); + }); + it('throws SITE_NOT_FOUND on 404', async () => { vi.stubGlobal( 'fetch', @@ -94,7 +111,7 @@ describe('postManifest', () => { await expect( postManifest( - { siteUuid: 'uuid', endpoint: 'https://example.com', timeoutMs: 30_000 }, + { siteUuid: 'uuid', endpoint: 'https://example.com', timeoutMs: 30_000, environment: 'production' }, { ecosystem: 'npm', packages: [] }, ), ).rejects.toMatchObject({ code: 'SITE_NOT_FOUND' }); @@ -112,7 +129,7 @@ describe('postManifest', () => { await expect( postManifest( - { siteUuid: 'uuid', endpoint: 'https://example.com', timeoutMs: 30_000 }, + { siteUuid: 'uuid', endpoint: 'https://example.com', timeoutMs: 30_000, environment: 'production' }, { ecosystem: 'npm', packages: [] }, ), ).rejects.toMatchObject({ code: 'VALIDATION_ERROR' }); @@ -123,7 +140,7 @@ describe('postManifest', () => { await expect( postManifest( - { siteUuid: 'uuid', endpoint: 'https://example.com', timeoutMs: 30_000 }, + { siteUuid: 'uuid', endpoint: 'https://example.com', timeoutMs: 30_000, environment: 'production' }, { ecosystem: 'npm', packages: [] }, ), ).rejects.toBeInstanceOf(PatchstackError); @@ -136,7 +153,7 @@ describe('postManifest', () => { vi.stubGlobal('fetch', fetchMock); await postManifest( - { siteUuid: 'uuid', endpoint: 'https://example.com', timeoutMs: 12345 }, + { siteUuid: 'uuid', endpoint: 'https://example.com', timeoutMs: 12345, environment: 'production' }, { ecosystem: 'npm', packages: [] }, ); @@ -154,7 +171,7 @@ describe('postManifest', () => { vi.stubGlobal('fetch', fetchMock); const result = await postManifest( - { siteUuid: null, endpoint: 'https://example.com/monitor/pulse/manifest', timeoutMs: 30_000 }, + { siteUuid: null, endpoint: 'https://example.com/monitor/pulse/manifest', timeoutMs: 30_000, environment: 'production' }, { ecosystem: 'npm', packages: [{ name: 'lodash', version: '4.17.21' }] }, ); @@ -171,7 +188,7 @@ describe('postManifest', () => { await expect( postManifest( - { siteUuid: 'uuid', endpoint: 'https://example.com', timeoutMs: 1 }, + { siteUuid: 'uuid', endpoint: 'https://example.com', timeoutMs: 1, environment: 'production' }, { ecosystem: 'npm', packages: [] }, ), ).rejects.toMatchObject({ code: 'NETWORK_TIMEOUT' }); diff --git a/tests/config.test.ts b/tests/config.test.ts index 5f1e26f..f9b9844 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -18,6 +18,7 @@ describe('resolveConfig', () => { delete process.env.PATCHSTACK_SITE_UUID; delete process.env.PATCHSTACK_ENDPOINT; delete process.env.PATCHSTACK_TIMEOUT_MS; + delete process.env.PATCHSTACK_ENVIRONMENT; }); afterEach(async () => { @@ -92,4 +93,35 @@ describe('resolveConfig', () => { code: 'CONFIG_INVALID', }); }); + + it('defaults the environment to production', async () => { + const config = await resolveConfig({ cwd, cliSiteUuid: VALID_UUID }); + expect(config.environment).toBe('production'); + }); + + it('reads PATCHSTACK_ENVIRONMENT from the environment', async () => { + process.env.PATCHSTACK_ENVIRONMENT = 'sandbox'; + const config = await resolveConfig({ cwd, cliSiteUuid: VALID_UUID }); + expect(config.environment).toBe('sandbox'); + }); + + it('reads environment from the config file', async () => { + await writeConfigFile(cwd, { siteUuid: VALID_UUID, environment: 'sandbox' }); + const config = await resolveConfig({ cwd }); + expect(config.environment).toBe('sandbox'); + }); + + it('lets PATCHSTACK_ENVIRONMENT override the file', async () => { + await writeConfigFile(cwd, { siteUuid: VALID_UUID, environment: 'sandbox' }); + process.env.PATCHSTACK_ENVIRONMENT = 'production'; + const config = await resolveConfig({ cwd }); + expect(config.environment).toBe('production'); + }); + + it('throws CONFIG_INVALID when the environment is not production or sandbox', async () => { + process.env.PATCHSTACK_ENVIRONMENT = 'staging'; + await expect(resolveConfig({ cwd, cliSiteUuid: VALID_UUID })).rejects.toMatchObject({ + code: 'CONFIG_INVALID', + }); + }); });