|
1 | 1 | import { promises as fs } from 'node:fs'
|
2 |
| -import { dirname } from 'node:path' |
| 2 | +import { dirname, resolve } from 'node:path' |
| 3 | +import semver from 'semver' |
| 4 | +import { chalk, logAndThrowError, log, version } from '../../utils/command-helpers.js' |
| 5 | +import type { RunRecipeOptions } from '../../commands/recipes/recipes.js' |
3 | 6 |
|
4 | 7 | const ATTRIBUTES_REGEX = /(\S*)="([^\s"]*)"/gim
|
5 |
| -const BASE_URL = 'https://docs.netlify.com/ai-context' |
6 |
| -export const FILE_NAME = 'netlify-development.mdc' |
| 8 | +// AI_CONTEXT_BASE_URL is used to help with local testing at non-production |
| 9 | +// versions of the context apis. |
| 10 | +const BASE_URL = new URL(process.env.AI_CONTEXT_BASE_URL ?? 'https://docs.netlify.com/ai-context').toString() |
| 11 | +export const NTL_DEV_MCP_FILE_NAME = 'netlify-development.mdc' |
7 | 12 | const MINIMUM_CLI_VERSION_HEADER = 'x-cli-min-ver'
|
8 | 13 | export const NETLIFY_PROVIDER = 'netlify'
|
9 | 14 | const PROVIDER_CONTEXT_REGEX = /<providercontext ([^>]*)>(.*)<\/providercontext>/ims
|
10 | 15 | const PROVIDER_CONTEXT_OVERRIDES_REGEX = /<providercontextoverrides([^>]*)>(.*)<\/providercontextoverrides>/ims
|
11 | 16 | const PROVIDER_CONTEXT_OVERRIDES_TAG = 'ProviderContextOverrides'
|
12 | 17 |
|
13 |
| -export const downloadFile = async (cliVersion: string) => { |
| 18 | +export interface ContextConfig { |
| 19 | + scope: string |
| 20 | + glob?: string |
| 21 | + shared?: string[] |
| 22 | + endpoint?: string |
| 23 | +} |
| 24 | + |
| 25 | +export interface ContextFile { |
| 26 | + key: string |
| 27 | + config: ContextConfig |
| 28 | + content: string |
| 29 | +} |
| 30 | + |
| 31 | +export interface ConsumerConfig { |
| 32 | + key: string |
| 33 | + presentedName: string |
| 34 | + consumerProcessCmd?: string |
| 35 | + path: string |
| 36 | + ext: string |
| 37 | + truncationLimit?: number |
| 38 | + contextScopes: Record<string, ContextConfig> |
| 39 | + hideFromCLI?: boolean |
| 40 | + consumerTrigger?: string |
| 41 | +} |
| 42 | + |
| 43 | +let contextConsumers: ConsumerConfig[] = [] |
| 44 | +export const getContextConsumers = async (cliVersion: string) => { |
| 45 | + if (contextConsumers.length > 0) { |
| 46 | + return contextConsumers |
| 47 | + } |
14 | 48 | try {
|
15 |
| - const res = await fetch(`${BASE_URL}/${FILE_NAME}`, { |
| 49 | + const res = await fetch(`${BASE_URL}/context-consumers`, { |
16 | 50 | headers: {
|
17 | 51 | 'user-agent': `NetlifyCLI ${cliVersion}`,
|
18 | 52 | },
|
19 | 53 | })
|
| 54 | + |
| 55 | + if (!res.ok) { |
| 56 | + return [] |
| 57 | + } |
| 58 | + |
| 59 | + const data = (await res.json()) as { consumers: ConsumerConfig[] } | undefined |
| 60 | + contextConsumers = data?.consumers ?? [] |
| 61 | + } catch {} |
| 62 | + |
| 63 | + return contextConsumers |
| 64 | +} |
| 65 | + |
| 66 | +export const downloadFile = async (cliVersion: string, contextConfig: ContextConfig, consumer: ConsumerConfig) => { |
| 67 | + try { |
| 68 | + if (!contextConfig.endpoint) { |
| 69 | + return null |
| 70 | + } |
| 71 | + |
| 72 | + const url = new URL(contextConfig.endpoint, BASE_URL) |
| 73 | + url.searchParams.set('consumer', consumer.key) |
| 74 | + |
| 75 | + if (process.env.AI_CONTEXT_BASE_URL) { |
| 76 | + const overridingUrl = new URL(process.env.AI_CONTEXT_BASE_URL) |
| 77 | + url.host = overridingUrl.host |
| 78 | + url.port = overridingUrl.port |
| 79 | + url.protocol = overridingUrl.protocol |
| 80 | + } |
| 81 | + |
| 82 | + const res = await fetch(url, { |
| 83 | + headers: { |
| 84 | + 'user-agent': `NetlifyCLI ${cliVersion}`, |
| 85 | + }, |
| 86 | + }) |
| 87 | + |
| 88 | + if (!res.ok) { |
| 89 | + return null |
| 90 | + } |
| 91 | + |
20 | 92 | const contents = await res.text()
|
21 | 93 | const minimumCLIVersion = res.headers.get(MINIMUM_CLI_VERSION_HEADER) ?? undefined
|
22 | 94 |
|
@@ -99,10 +171,12 @@ export const applyOverrides = (template: string, overrides?: string) => {
|
99 | 171 | return template
|
100 | 172 | }
|
101 | 173 |
|
102 |
| - return template.replace( |
103 |
| - PROVIDER_CONTEXT_OVERRIDES_REGEX, |
104 |
| - `<${PROVIDER_CONTEXT_OVERRIDES_TAG}>${overrides}</${PROVIDER_CONTEXT_OVERRIDES_TAG}>`, |
105 |
| - ) |
| 174 | + return template |
| 175 | + .replace( |
| 176 | + PROVIDER_CONTEXT_OVERRIDES_REGEX, |
| 177 | + `<${PROVIDER_CONTEXT_OVERRIDES_TAG}>${overrides}</${PROVIDER_CONTEXT_OVERRIDES_TAG}>`, |
| 178 | + ) |
| 179 | + .trim() |
106 | 180 | }
|
107 | 181 |
|
108 | 182 | /**
|
@@ -137,3 +211,84 @@ export const writeFile = async (path: string, contents: string) => {
|
137 | 211 | await fs.mkdir(directory, { recursive: true })
|
138 | 212 | await fs.writeFile(path, contents)
|
139 | 213 | }
|
| 214 | + |
| 215 | +export const deleteFile = async (path: string) => { |
| 216 | + try { |
| 217 | + // delete file from file system - not just unlinking it |
| 218 | + await fs.rm(path) |
| 219 | + } catch { |
| 220 | + // ignore |
| 221 | + } |
| 222 | +} |
| 223 | + |
| 224 | +export const downloadAndWriteContextFiles = async (consumer: ConsumerConfig, { command }: RunRecipeOptions) => { |
| 225 | + await Promise.allSettled( |
| 226 | + Object.keys(consumer.contextScopes).map(async (contextKey) => { |
| 227 | + const contextConfig = consumer.contextScopes[contextKey] |
| 228 | + |
| 229 | + const { contents: downloadedFile, minimumCLIVersion } = |
| 230 | + (await downloadFile(version, contextConfig, consumer).catch(() => null)) ?? {} |
| 231 | + |
| 232 | + if (!downloadedFile) { |
| 233 | + return logAndThrowError( |
| 234 | + `An error occurred when pulling the latest context file for scope ${contextConfig.scope}. Please try again.`, |
| 235 | + ) |
| 236 | + } |
| 237 | + if (minimumCLIVersion && semver.lt(version, minimumCLIVersion)) { |
| 238 | + return logAndThrowError( |
| 239 | + `This command requires version ${minimumCLIVersion} or above of the Netlify CLI. Refer to ${chalk.underline( |
| 240 | + 'https://ntl.fyi/update-cli', |
| 241 | + )} for information on how to update.`, |
| 242 | + ) |
| 243 | + } |
| 244 | + |
| 245 | + const absoluteFilePath = resolve( |
| 246 | + command?.workingDir ?? '', |
| 247 | + consumer.path, |
| 248 | + `netlify-${contextKey}.${consumer.ext || 'mdc'}`, |
| 249 | + ) |
| 250 | + |
| 251 | + const existing = await getExistingContext(absoluteFilePath) |
| 252 | + const remote = parseContextFile(downloadedFile) |
| 253 | + |
| 254 | + let { contents } = remote |
| 255 | + |
| 256 | + // Does a file already exist at this path? |
| 257 | + if (existing) { |
| 258 | + // If it's a file we've created, let's check the version and bail if we're |
| 259 | + // already on the latest, otherwise rewrite it with the latest version. |
| 260 | + if (existing.provider?.toLowerCase() === NETLIFY_PROVIDER) { |
| 261 | + if (remote.version === existing.version) { |
| 262 | + log( |
| 263 | + `You're all up to date! ${chalk.underline( |
| 264 | + absoluteFilePath, |
| 265 | + )} contains the latest version of the context files.`, |
| 266 | + ) |
| 267 | + return |
| 268 | + } |
| 269 | + |
| 270 | + // We must preserve any overrides found in the existing file. |
| 271 | + contents = applyOverrides(remote.contents, existing.overrides?.innerContents) |
| 272 | + } else { |
| 273 | + // Whatever exists in the file goes in the overrides block. |
| 274 | + contents = applyOverrides(remote.contents, existing.contents) |
| 275 | + } |
| 276 | + } |
| 277 | + |
| 278 | + // we don't want to cut off content, but if we _have_ to |
| 279 | + // then we need to do so before writing or the user's |
| 280 | + // context gets in a bad state. Note, this can result in |
| 281 | + // a file that's not parsable next time. This will be |
| 282 | + // fine because the file will simply be replaced. Not ideal |
| 283 | + // but solves the issue of a truncated file in a bad state |
| 284 | + // being updated. |
| 285 | + if (consumer.truncationLimit && contents.length > consumer.truncationLimit) { |
| 286 | + contents = contents.slice(0, consumer.truncationLimit) |
| 287 | + } |
| 288 | + |
| 289 | + await writeFile(absoluteFilePath, contents) |
| 290 | + |
| 291 | + log(`${existing ? 'Updated' : 'Created'} context files at ${chalk.underline(absoluteFilePath)}`) |
| 292 | + }), |
| 293 | + ) |
| 294 | +} |
0 commit comments