UNPKG

netlify

Version:

Netlify command line tool

195 lines 8.01 kB
import { promises as fs } from 'node:fs'; import { dirname, resolve } from 'node:path'; import semver from 'semver'; import { chalk, logAndThrowError, log, version } from '../../utils/command-helpers.js'; const ATTRIBUTES_REGEX = /(\S*)="([^\s"]*)"/gim; // AI_CONTEXT_BASE_URL is used to help with local testing at non-production // versions of the context apis. const BASE_URL = new URL(process.env.AI_CONTEXT_BASE_URL ?? 'https://docs.netlify.com/ai-context').toString(); export const NTL_DEV_MCP_FILE_NAME = 'netlify-development.mdc'; const MINIMUM_CLI_VERSION_HEADER = 'x-cli-min-ver'; export const NETLIFY_PROVIDER = 'netlify'; const PROVIDER_CONTEXT_REGEX = /<providercontext ([^>]*)>(.*)<\/providercontext>/ims; const PROVIDER_CONTEXT_OVERRIDES_REGEX = /<providercontextoverrides([^>]*)>(.*)<\/providercontextoverrides>/ims; const PROVIDER_CONTEXT_OVERRIDES_TAG = 'ProviderContextOverrides'; let contextConsumers = []; export const getContextConsumers = async (cliVersion) => { if (contextConsumers.length > 0) { return contextConsumers; } try { const res = await fetch(`${BASE_URL}/context-consumers`, { headers: { 'user-agent': `NetlifyCLI ${cliVersion}`, }, }); if (!res.ok) { return []; } const data = (await res.json()); contextConsumers = data?.consumers ?? []; } catch { } return contextConsumers; }; export const downloadFile = async (cliVersion, contextConfig, consumer) => { try { if (!contextConfig.endpoint) { return null; } const url = new URL(contextConfig.endpoint, BASE_URL); url.searchParams.set('consumer', consumer.key); if (process.env.AI_CONTEXT_BASE_URL) { const overridingUrl = new URL(process.env.AI_CONTEXT_BASE_URL); url.host = overridingUrl.host; url.port = overridingUrl.port; url.protocol = overridingUrl.protocol; } const res = await fetch(url, { headers: { 'user-agent': `NetlifyCLI ${cliVersion}`, }, }); if (!res.ok) { return null; } const contents = await res.text(); const minimumCLIVersion = res.headers.get(MINIMUM_CLI_VERSION_HEADER) ?? undefined; return { contents, minimumCLIVersion, }; } catch { // no-op } return null; }; /** * Parses the `<ProviderContext>` and `<ProviderContextOverrides>` blocks in * a context file. */ export const parseContextFile = (contents) => { const result = { contents, }; const providerContext = contents.match(PROVIDER_CONTEXT_REGEX); if (providerContext) { const [, attributes, innerContents] = providerContext; result.innerContents = innerContents; for (const [, name, value] of attributes.matchAll(ATTRIBUTES_REGEX)) { switch (name.toLowerCase()) { case 'provider': result.provider = value; break; case 'version': result.version = value; break; default: continue; } } } const contextOverrides = contents.match(PROVIDER_CONTEXT_OVERRIDES_REGEX); if (contextOverrides) { const [overrideContents, , innerContents] = contextOverrides; result.overrides = { contents: overrideContents, innerContents, }; } return result; }; /** * Takes a context file (a template) and injects a string in an overrides block * if one is found. Returns the resulting context file. */ export const applyOverrides = (template, overrides) => { if (!overrides) { return template; } return template .replace(PROVIDER_CONTEXT_OVERRIDES_REGEX, `<${PROVIDER_CONTEXT_OVERRIDES_TAG}>${overrides}</${PROVIDER_CONTEXT_OVERRIDES_TAG}>`) .trim(); }; /** * Reads a file on disk and tries to parse it as a context file. */ export const getExistingContext = async (path) => { try { const stats = await fs.stat(path); if (!stats.isFile()) { throw new Error(`${path} already exists but is not a file. Please remove it or rename it and try again.`); } const file = await fs.readFile(path, 'utf8'); const parsedFile = parseContextFile(file); return parsedFile; } catch (error) { const exception = error; if (exception.code !== 'ENOENT') { throw new Error(`Could not open context file at ${path}: ${exception.message}`); } return null; } }; export const writeFile = async (path, contents) => { const directory = dirname(path); await fs.mkdir(directory, { recursive: true }); await fs.writeFile(path, contents); }; export const deleteFile = async (path) => { try { // delete file from file system - not just unlinking it await fs.rm(path); } catch { // ignore } }; export const downloadAndWriteContextFiles = async (consumer, { command }) => { await Promise.allSettled(Object.keys(consumer.contextScopes).map(async (contextKey) => { const contextConfig = consumer.contextScopes[contextKey]; const { contents: downloadedFile, minimumCLIVersion } = (await downloadFile(version, contextConfig, consumer).catch(() => null)) ?? {}; if (!downloadedFile) { return logAndThrowError(`An error occurred when pulling the latest context file for scope ${contextConfig.scope}. Please try again.`); } if (minimumCLIVersion && semver.lt(version, minimumCLIVersion)) { return logAndThrowError(`This command requires version ${minimumCLIVersion} or above of the Netlify CLI. Refer to ${chalk.underline('https://ntl.fyi/update-cli')} for information on how to update.`); } const absoluteFilePath = resolve(command?.workingDir ?? '', consumer.path, `netlify-${contextKey}.${consumer.ext || 'mdc'}`); const existing = await getExistingContext(absoluteFilePath); const remote = parseContextFile(downloadedFile); let { contents } = remote; // Does a file already exist at this path? if (existing) { // If it's a file we've created, let's check the version and bail if we're // already on the latest, otherwise rewrite it with the latest version. if (existing.provider?.toLowerCase() === NETLIFY_PROVIDER) { if (remote.version === existing.version) { log(`You're all up to date! ${chalk.underline(absoluteFilePath)} contains the latest version of the context files.`); return; } // We must preserve any overrides found in the existing file. contents = applyOverrides(remote.contents, existing.overrides?.innerContents); } else { // Whatever exists in the file goes in the overrides block. contents = applyOverrides(remote.contents, existing.contents); } } // we don't want to cut off content, but if we _have_ to // then we need to do so before writing or the user's // context gets in a bad state. Note, this can result in // a file that's not parsable next time. This will be // fine because the file will simply be replaced. Not ideal // but solves the issue of a truncated file in a bad state // being updated. if (consumer.truncationLimit && contents.length > consumer.truncationLimit) { contents = contents.slice(0, consumer.truncationLimit); } await writeFile(absoluteFilePath, contents); log(`${existing ? 'Updated' : 'Created'} context files at ${chalk.underline(absoluteFilePath)}`); })); }; //# sourceMappingURL=context.js.map