Skip to content

Commit dfab3a3

Browse files
author
Caleb Barnes
authored
feat(config): handle auto installable extensions (#6284)
* feat: handle auto installable extensions * feature flag "auto_install_required_extensions" * fetch list of auto installable extensions from jigsaw "/meta/auto-installable" * using "@netlify/build-info" for project.getPackageJSON() * detect matching installed packages to determine which extensions to auto install * automatically install the extension on the team * fix: rename requiredExt -> installableExt Just renaming * fix: remove console log remove console log opts * chore: remove unnecessary netlifyToken variables just using opts.token directly now * feat: remove @netlify/build-info and just read package json with fs build-info had some deps that broke things for older versions of node like 14~ * fix: addressing PR feedback * use require to load package.json instead of accessing file directly * reduce additional API calls made on each build down to 1 by re-using what we already have * if extensions do get installed, update the apiIntegrations and pass to mergeIntegrations so it is included in the final result * remove unnecessary getJigsawToken call
1 parent 82cf0f2 commit dfab3a3

File tree

4 files changed

+193
-2
lines changed

4 files changed

+193
-2
lines changed

packages/config/src/api/site_info.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ type GetIntegrationsOpts = {
138138
mode: ModeOption
139139
}
140140

141-
const getIntegrations = async function ({
141+
export const getIntegrations = async function ({
142142
siteId,
143143
accountId,
144144
testOpts,

packages/config/src/main.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { UI_ORIGIN, CONFIG_ORIGIN, INLINE_ORIGIN } from './origin.js'
2323
import { parseConfig } from './parse.js'
2424
import { getConfigPath } from './path.js'
2525
import { getRedirectsPath, addRedirects } from './redirects.js'
26+
import { handleAutoInstallExtensions } from './utils/extensions/auto-install-extensions.js'
2627

2728
export type Config = {
2829
accounts: MinimalAccount[] | undefined
@@ -169,8 +170,22 @@ export const resolveConfig = async function (opts): Promise<Config> {
169170
// @todo Remove in the next major version.
170171
const configA = addLegacyFunctionsDirectory(config)
171172

173+
const updatedIntegrations = await handleAutoInstallExtensions({
174+
featureFlags,
175+
accounts,
176+
integrations,
177+
siteId,
178+
accountId,
179+
token,
180+
cwd,
181+
extensionApiBaseUrl,
182+
testOpts,
183+
offline,
184+
mode,
185+
})
186+
172187
const mergedIntegrations = await mergeIntegrations({
173-
apiIntegrations: integrations,
188+
apiIntegrations: updatedIntegrations,
174189
configIntegrations: configA.integrations,
175190
context: context,
176191
})
@@ -192,6 +207,7 @@ export const resolveConfig = async function (opts): Promise<Config> {
192207
api,
193208
logs,
194209
}
210+
195211
logResult(result, { logs, debug })
196212
return result
197213
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { createRequire } from 'module'
2+
import { join } from 'path'
3+
4+
import { getIntegrations } from '../../api/site_info.js'
5+
import { type IntegrationResponse } from '../../types/api.js'
6+
import { type ModeOption } from '../../types/options.js'
7+
8+
import { fetchAutoInstallableExtensionsMeta, installExtension } from './utils.js'
9+
10+
function getPackageJSON(directory: string) {
11+
const require = createRequire(join(directory, 'package.json'))
12+
return require('./package.json')
13+
}
14+
15+
interface AutoInstallOptions {
16+
featureFlags: any
17+
siteId: string
18+
accountId: string
19+
token: string
20+
cwd: string
21+
accounts: any
22+
integrations: IntegrationResponse[]
23+
offline: boolean
24+
testOpts: any
25+
mode: ModeOption
26+
extensionApiBaseUrl: string
27+
}
28+
29+
export async function handleAutoInstallExtensions({
30+
featureFlags,
31+
siteId,
32+
accountId,
33+
token,
34+
cwd,
35+
accounts,
36+
integrations,
37+
offline,
38+
testOpts = {},
39+
mode,
40+
extensionApiBaseUrl,
41+
}: AutoInstallOptions) {
42+
if (!featureFlags?.auto_install_required_extensions || !accountId || !siteId || !token || !cwd || offline) {
43+
return integrations
44+
}
45+
const account = accounts?.find((account: any) => account.id === accountId)
46+
if (!account) {
47+
return integrations
48+
}
49+
try {
50+
const packageJson = getPackageJSON(cwd)
51+
if (
52+
!packageJson?.dependencies ||
53+
typeof packageJson?.dependencies !== 'object' ||
54+
Object.keys(packageJson?.dependencies)?.length === 0
55+
) {
56+
return integrations
57+
}
58+
59+
const autoInstallableExtensions = await fetchAutoInstallableExtensionsMeta()
60+
const extensionsToInstall = autoInstallableExtensions.filter((ext) => {
61+
return !integrations?.some((integration) => integration.slug === ext.slug)
62+
})
63+
if (extensionsToInstall.length === 0) {
64+
return integrations
65+
}
66+
67+
const results = await Promise.all(
68+
extensionsToInstall.map(async (ext) => {
69+
console.log(
70+
`Installing extension "${ext.slug}" on team "${account.name}" required by package(s): "${ext.packages.join(
71+
'",',
72+
)}"`,
73+
)
74+
return installExtension({
75+
accountId,
76+
netlifyToken: token,
77+
slug: ext.slug,
78+
hostSiteUrl: ext.hostSiteUrl,
79+
})
80+
}),
81+
)
82+
83+
if (results.length > 0 && results.some((result) => !result.error)) {
84+
return getIntegrations({
85+
siteId,
86+
accountId,
87+
testOpts,
88+
offline,
89+
token,
90+
featureFlags,
91+
extensionApiBaseUrl,
92+
mode,
93+
})
94+
}
95+
return integrations
96+
} catch (error) {
97+
console.error(`Failed to auto install extension(s): ${error.message}`, error)
98+
return integrations
99+
}
100+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { EXTENSION_API_BASE_URL } from '../../integrations.js'
2+
3+
export type InstallExtensionResult =
4+
| {
5+
slug: string
6+
error: null
7+
}
8+
| {
9+
slug: string
10+
error: {
11+
code: string
12+
message: string
13+
}
14+
}
15+
16+
export const installExtension = async ({
17+
netlifyToken,
18+
accountId,
19+
slug,
20+
hostSiteUrl,
21+
}: {
22+
netlifyToken: string
23+
accountId: string
24+
slug: string
25+
hostSiteUrl: string
26+
}): Promise<InstallExtensionResult> => {
27+
const extensionOnInstallUrl = new URL('/.netlify/functions/handler/on-install', hostSiteUrl)
28+
const installedResponse = await fetch(extensionOnInstallUrl, {
29+
method: 'POST',
30+
body: JSON.stringify({
31+
teamId: accountId,
32+
}),
33+
headers: {
34+
'netlify-token': netlifyToken,
35+
},
36+
})
37+
38+
if (!installedResponse.ok && installedResponse.status !== 409) {
39+
const text = await installedResponse.text()
40+
return {
41+
slug,
42+
error: {
43+
code: installedResponse.status.toString(),
44+
message: text,
45+
},
46+
}
47+
}
48+
return {
49+
slug,
50+
error: null,
51+
}
52+
}
53+
54+
type AutoInstallableExtensionMeta = {
55+
slug: string
56+
hostSiteUrl: string
57+
packages: string[]
58+
}
59+
/**
60+
* Fetches the list of extensions from Jigsaw that declare associated packages.
61+
* Used to determine which extensions should be auto-installed based on the packages
62+
* present in the package.json (e.g., if an extension lists '@netlify/neon',
63+
* and that package exists in package.json, the extension will be auto-installed).
64+
*
65+
* @returns Array of extensions with their associated packages
66+
*/
67+
export async function fetchAutoInstallableExtensionsMeta(): Promise<AutoInstallableExtensionMeta[]> {
68+
const url = new URL(`/meta/auto-installable`, process.env.EXTENSION_API_BASE_URL ?? EXTENSION_API_BASE_URL)
69+
const response = await fetch(url.toString())
70+
if (!response.ok) {
71+
throw new Error(`Failed to fetch extensions meta`)
72+
}
73+
const data = await response.json()
74+
return data as AutoInstallableExtensionMeta[]
75+
}

0 commit comments

Comments
 (0)