diff --git a/src/commands/database/constants.ts b/src/commands/database/constants.ts new file mode 100644 index 00000000000..f5bcd52ab7a --- /dev/null +++ b/src/commands/database/constants.ts @@ -0,0 +1,3 @@ +export const NEON_DATABASE_EXTENSION_SLUG = process.env.NEON_DATABASE_EXTENSION_SLUG ?? '7jjmnqyo-netlify-neon' +export const JIGSAW_URL = process.env.JIGSAW_URL ?? 'https://api.netlifysdk.com' +export const NETLIFY_WEB_UI = process.env.NETLIFY_WEB_UI ?? 'https://app.netlify.com' diff --git a/src/commands/database/database.ts b/src/commands/database/database.ts new file mode 100644 index 00000000000..f10f9cd21cf --- /dev/null +++ b/src/commands/database/database.ts @@ -0,0 +1,43 @@ +import BaseCommand from '../base-command.js' +import { dev } from './dev-branch.js' +import { status } from './status.js' +import { init } from './init.js' + +export type Extension = { + name: string + slug: string + hostSiteUrl: string + installedOnTeam: boolean +} + +export type SiteInfo = { + id: string + name: string + account_id: string + admin_url: string + url: string + ssl_url: string +} + +export const createDatabaseCommand = (program: BaseCommand) => { + const dbCommand = program.command('db').alias('database').description(`TODO: write description for database command`) + + dbCommand + .command('init') + .description('Initialize a new database') + + .option('--no-drizzle', 'Skips drizzle') + .option('-y, --yes', 'Skip prompts and use default values') + .option('-o, --overwrite', 'Overwrites existing files that would be created when setting up drizzle') + .action(init) + + dbCommand + .command('dev') + .description('Set up a local development database branch') + .option('--reset', 'Resets the development branch to the current state of main') + .option('--init', 'Sets up a local development branch for the current user') + .action(dev) + dbCommand.command('status').description('Check the status of the database').action(status) + + return dbCommand +} diff --git a/src/commands/database/dev-branch.ts b/src/commands/database/dev-branch.ts new file mode 100644 index 00000000000..49810e9b6cb --- /dev/null +++ b/src/commands/database/dev-branch.ts @@ -0,0 +1,206 @@ +import BaseCommand from '../base-command.js' +import { Extension, SiteInfo } from './database.js' +import { OptionValues } from 'commander' +import { getExtension } from './utils.js' +import { NEON_DATABASE_EXTENSION_SLUG } from './constants.js' +import { log } from 'console' +import inquirer from 'inquirer' +import prettyjson from 'prettyjson' +import { createDrizzleDevConfig } from './drizzle.js' + +export const dev = async (_options: OptionValues, command: BaseCommand) => { + const siteInfo = command.netlify.siteInfo as SiteInfo + if (!command.siteId) { + console.error(`The project must be linked with netlify link before setting up a local database.`) + return + } + + const netlifyToken = command.netlify.api.accessToken?.replace('Bearer ', '') + if (!netlifyToken) { + throw new Error(`Please login with netlify login before running this command`) + } + + const extensionData = await getExtension({ + accountId: siteInfo.account_id, + token: netlifyToken, + slug: NEON_DATABASE_EXTENSION_SLUG, + }) + + const extension: Extension = extensionData + ? { + name: extensionData.name, + hostSiteUrl: extensionData.hostSiteUrl, + slug: NEON_DATABASE_EXTENSION_SLUG, // Add the slug from the parameter + installedOnTeam: extensionData.installedOnTeam, + } + : (undefined as unknown as Extension) + + if (!extension.hostSiteUrl) { + throw new Error(`Failed to get extension host site url`) + } + + const headers = { + 'Content-Type': 'application/json', + 'nf-db-token': netlifyToken, + 'nf-db-site-id': command.siteId, + 'nf-db-account-id': siteInfo.account_id, + } + + const initialOpts = command.opts() + + type Answers = { + resetBranch: boolean + createDevBranch: boolean + } + + const { existingDevBranchName } = await getDevBranchInfo({ headers, command, extension }) + + if ((!initialOpts.init || initialOpts.reset) && !existingDevBranchName) { + log('No existing development branch found for this user and site') + log('If you want to create one, run `netlify db dev --init`') + return + } + + if (initialOpts.init && existingDevBranchName) { + log(`Development branch ${existingDevBranchName} already exists for this user and site`) + return + } else if (initialOpts.init) { + const answers = await inquirer.prompt([ + { + type: 'confirm', + name: 'createDevBranch', + message: `Are you sure you want to create a new development branch for this user and site?`, + }, + ]) + + if (answers.createDevBranch) { + const { uri, name } = await createDevBranch({ headers, command, extension }) + // if we can see that we are using drizzle, create the drizzle config + await createDrizzleDevConfig(command, { devBranchUri: uri }) + log(`Created new development branch: ${name}`) + return + } + } + + if (initialOpts.reset && !existingDevBranchName) { + log('No existing development branch found for this user and site') + log('If you want to create one, run `netlify db dev --init`') + return + } + /** + * If --reset was passed, prompt for confirmation that they want to reset their local branch + */ + if (initialOpts.reset && existingDevBranchName) { + const answers = await inquirer.prompt([ + { + type: 'confirm', + name: 'resetBranch', + message: `Are you sure you want to reset your current branch ${existingDevBranchName} to the current state of main?`, + }, + ]) + + if (answers.resetBranch) { + const resetInfo = await reset({ headers, command, extension }) + log(prettyjson.render(resetInfo)) + return + } + } + + log( + prettyjson.render({ + 'Your dev branch': existingDevBranchName, + }), + ) + return +} + +export const reset = async ({ + headers, + command, + extension, +}: { + headers: Record + command: BaseCommand + extension: Extension +}) => { + const hostSiteUrl = getHostSiteUrl(command, extension) + const devBranchResetEndpoint = new URL('/reset-dev-branch', hostSiteUrl).toString() + const req = await fetch(devBranchResetEndpoint, { + method: 'POST', + headers, + }) + + if (!req.ok) { + throw new Error(`Failed to reset database: ${await req.text()}`) + } + const res = await req.json() + return res +} + +export const createDevBranch = async ({ + headers, + command, + extension, +}: { + headers: Record + command: BaseCommand + extension: Extension +}) => { + const hostSiteUrl = getHostSiteUrl(command, extension) + const devBranchInfoEndpoint = new URL('/create-dev-branch', hostSiteUrl).toString() + + const req = await fetch(devBranchInfoEndpoint, { + method: 'POST', + headers, + }) + + if (!req.ok) { + throw new Error(`Failed to create dev branch: ${await req.text()}`) + } + const res = await req.json() + const { uri, name } = res as { uri: string; name: string } + + return { uri, name } +} + +export const getDevBranchInfo = async ({ + headers, + command, + extension, +}: { + headers: Record + command: BaseCommand + extension: Extension +}) => { + const hostSiteUrl = getHostSiteUrl(command, extension) + const devBranchInfoEndpoint = new URL('/get-dev-branch', hostSiteUrl).toString() + + const req = await fetch(devBranchInfoEndpoint, { + method: 'GET', + headers, + }) + + if (!req.ok) { + throw new Error(`Failed to get database information: ${await req.text()}`) + } + const res = (await req.json()) as { localDevBranch: { name: string } | null } + + if (!res.localDevBranch) { + return { existingDevBranchName: undefined } + } + const { + localDevBranch: { name: existingDevBranchName }, + } = res + + return { existingDevBranchName } +} + +const getHostSiteUrl = (command: BaseCommand, extension: Extension) => { + const { + // @ts-expect-error types are weird here + build_settings: { env: siteEnv = {} }, + } = command.netlify.siteInfo + const NEON_DATABASE_EXTENSION_HOST_SITE_URL = (siteEnv as Record) + .NEON_DATABASE_EXTENSION_HOST_SITE_URL as string | undefined + return NEON_DATABASE_EXTENSION_HOST_SITE_URL ?? extension.hostSiteUrl +} diff --git a/src/commands/database/drizzle.ts b/src/commands/database/drizzle.ts new file mode 100644 index 00000000000..5c4751948cf --- /dev/null +++ b/src/commands/database/drizzle.ts @@ -0,0 +1,188 @@ +import { spawn } from 'child_process' +import { carefullyWriteFile } from './utils.js' +import BaseCommand from '../base-command.js' +import path from 'path' +import fs from 'fs/promises' +import inquirer from 'inquirer' + +export const initDrizzle = async (command: BaseCommand) => { + if (!command.project.root) { + throw new Error('Failed to initialize Drizzle in project. Project root not found.') + } + const opts = command.opts<{ + overwrite?: true | undefined + devBranchUri?: string | undefined + }>() + const devBranchUri = opts.devBranchUri + const drizzleDevConfigFilePath = path.resolve(command.project.root, 'drizzle-dev.config.ts') + const drizzleConfigFilePath = path.resolve(command.project.root, 'drizzle-prod.config.ts') + const schemaFilePath = path.resolve(command.project.root, 'db/schema.ts') + const dbIndexFilePath = path.resolve(command.project.root, 'db/index.ts') + if (opts.overwrite) { + if (devBranchUri) { + await fs.writeFile(drizzleDevConfigFilePath, createDrizzleDevConfigContent(devBranchUri)) + } + await fs.writeFile(drizzleConfigFilePath, drizzleConfig) + await fs.mkdir(path.resolve(command.project.root, 'db'), { recursive: true }) + await fs.writeFile(schemaFilePath, exampleDrizzleSchema) + await fs.writeFile(dbIndexFilePath, exampleDbIndex) + } else { + if (devBranchUri) { + await carefullyWriteFile( + drizzleDevConfigFilePath, + createDrizzleDevConfigContent(devBranchUri), + command.project.root, + ) + } + await carefullyWriteFile(drizzleConfigFilePath, drizzleConfig, command.project.root) + await fs.mkdir(path.resolve(command.project.root, 'db'), { recursive: true }) + await carefullyWriteFile(schemaFilePath, exampleDrizzleSchema, command.project.root) + await carefullyWriteFile(dbIndexFilePath, exampleDbIndex, command.project.root) + } + + const packageJsonPath = path.resolve(command.project.root, 'package.json') + + if (devBranchUri) { + const gitignorePath = path.resolve(command.project.root, '.gitignore') + try { + const gitignoreContent = (await fs.readFile(gitignorePath)).toString() + if (!gitignoreContent.includes('drizzle-dev.config.ts')) { + await fs.writeFile(gitignorePath, `${gitignoreContent}\ndrizzle-dev.config.ts\n`, { + flag: 'a', + }) + } + } catch { + await fs.writeFile(gitignorePath, 'drizzle-dev.config.ts\n') + } + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf-8')) + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + packageJson.scripts = { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + ...(packageJson.scripts ?? {}), + ...packageJsonScripts, + } + if (opts.overwrite) { + await fs.writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2)) + } + + type Answers = { + updatePackageJson: boolean + localDevBranch: boolean + } + const answers = await inquirer.prompt([ + { + type: 'confirm', + name: 'localDevBranch', + message: `Add a development database branch?`, + }, + ]) + if (answers.localDevBranch) { + console.log('cool') + return + } + + if (!opts.overwrite) { + const answers = await inquirer.prompt([ + { + type: 'confirm', + name: 'updatePackageJson', + message: `Add drizzle db commands to package.json?`, + }, + ]) + if (answers.updatePackageJson) { + await fs.writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2)) + } + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access + if (!Object.keys(packageJson?.devDependencies ?? {}).includes('drizzle-kit')) { + await spawnAsync(command.project.packageManager?.installCommand ?? 'npm install', ['drizzle-kit@latest', '-D'], { + stdio: 'inherit', + shell: true, + }) + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access + if (!Object.keys(packageJson?.dependencies ?? {}).includes('drizzle-orm')) { + await spawnAsync(command.project.packageManager?.installCommand ?? 'npm install', ['drizzle-orm@latest'], { + stdio: 'inherit', + shell: true, + }) + } +} + +const drizzleConfig = `import { defineConfig } from 'drizzle-kit'; + +export default defineConfig({ + dialect: 'postgresql', + dbCredentials: { + url: process.env.NETLIFY_DATABASE_URL! + }, + schema: './db/schema.ts', + out: './migrations' +});` + +const createDrizzleDevConfigContent = (devBranchUri: string) => `import { defineConfig } from 'drizzle-kit'; + +export default defineConfig({ + dialect: 'postgresql', + dbCredentials: { + url: '${devBranchUri}' + }, + schema: './db/schema.ts', + out: './migrations' +});` + +const exampleDrizzleSchema = `import { integer, pgTable, varchar, text } from 'drizzle-orm/pg-core'; + +export const posts = pgTable('posts', { + id: integer().primaryKey().generatedAlwaysAsIdentity(), + title: varchar({ length: 255 }).notNull(), + content: text().notNull().default('') +});` + +const exampleDbIndex = `import { neon } from '@netlify/neon'; +import { drizzle } from 'drizzle-orm/neon-http'; + +import * as schema from 'db/schema'; + +export const db = drizzle({ + schema, + client: neon() +});` + +const packageJsonScripts = { + 'db:generate': 'netlify dev:exec --context dev drizzle-kit generate', + 'db:migrate': 'netlify dev:exec --context dev drizzle-kit migrate', + 'db:studio': 'netlify dev:exec --context dev drizzle-kit studio', + 'db:push': 'netlify dev:exec --context dev drizzle-kit push', +} + +const spawnAsync = (command: string, args: string[], options: Parameters[2]): Promise => { + return new Promise((resolve, reject) => { + const child = spawn(command, args, options) + child.on('error', reject) + child.on('exit', (code) => { + if (code === 0) { + resolve(code) + } + const errorMessage = code ? `Process exited with code ${code.toString()}` : 'Process exited with no code' + reject(new Error(errorMessage)) + }) + }) +} + +export const createDrizzleDevConfig = async (command: BaseCommand, opts: { devBranchUri: string }) => { + if (!command.project.root) { + throw new Error('Failed to initialize Drizzle in project. Project root not found.') + } + + const drizzleDevConfigFilePath = path.resolve(command.project.root, 'drizzle-dev.config.ts') + await carefullyWriteFile( + drizzleDevConfigFilePath, + createDrizzleDevConfigContent(opts.devBranchUri), + command.project.root, + ) +} diff --git a/src/commands/database/index.ts b/src/commands/database/index.ts new file mode 100644 index 00000000000..27d2ca25f54 --- /dev/null +++ b/src/commands/database/index.ts @@ -0,0 +1 @@ +export { createDatabaseCommand } from './database.js' diff --git a/src/commands/database/init.ts b/src/commands/database/init.ts new file mode 100644 index 00000000000..5c6985e730b --- /dev/null +++ b/src/commands/database/init.ts @@ -0,0 +1,180 @@ +import { OptionValues } from 'commander' +import inquirer from 'inquirer' +import BaseCommand from '../base-command.js' +import { getAccount, getExtension, installExtension } from './utils.js' +import { initDrizzle } from './drizzle.js' +import { NEON_DATABASE_EXTENSION_SLUG } from './constants.js' +import prettyjson from 'prettyjson' +import { log } from '../../utils/command-helpers.js' +import { SiteInfo } from './database.js' +import { createDevBranch } from './dev-branch.js' + +export const init = async (_options: OptionValues, command: BaseCommand) => { + const siteInfo = command.netlify.siteInfo as SiteInfo + if (!command.siteId) { + console.error(`The project must be linked with netlify link before initializing a database.`) + return + } + + const initialOpts = command.opts() + + type Answers = { + drizzle: boolean + installExtension: boolean + useDevBranch: boolean + } + + const opts = command.opts<{ + drizzle?: boolean | undefined + /** + * Skip prompts and use default values (answer yes to all prompts) + */ + yes?: true | undefined + useDevBranch?: boolean | undefined + devBranchUri?: string | undefined + }>() + + if (!command.netlify.api.accessToken || !siteInfo.account_id || !siteInfo.name) { + throw new Error(`Please login with netlify login before running this command`) + } + + const account = await getAccount(command, { accountId: siteInfo.account_id }) + + const netlifyToken = command.netlify.api.accessToken.replace('Bearer ', '') + const extension = await getExtension({ + accountId: siteInfo.account_id, + token: netlifyToken, + slug: NEON_DATABASE_EXTENSION_SLUG, + }) + if (!extension?.hostSiteUrl) { + throw new Error(`Failed to get extension host site url when installing extension`) + } + + const installNeonExtension = async () => { + if (!account.name) { + throw new Error(`Failed to install extension "${extension.name}"`) + } + const installed = await installExtension({ + accountId: siteInfo.account_id, + token: netlifyToken, + slug: NEON_DATABASE_EXTENSION_SLUG, + hostSiteUrl: extension.hostSiteUrl, + }) + if (!installed) { + throw new Error(`Failed to install extension on team "${account.name}": "${extension.name}"`) + } + log(`Extension "${extension.name}" successfully installed on team "${account.name}"`) + } + + if (!extension.installedOnTeam && !opts.yes) { + const answers = await inquirer.prompt([ + { + type: 'confirm', + name: 'installExtension', + message: `The required extension "${extension.name}" is not installed on team "${account.name}", would you like to install it now?`, + }, + ]) + if (answers.installExtension) { + await installNeonExtension() + } else { + return + } + } + if (!extension.installedOnTeam && opts.yes) { + await installNeonExtension() + } + /** + * Only prompt for drizzle if the user did not pass in the `--drizzle` or `--no-drizzle` option + */ + if (initialOpts.drizzle !== false && initialOpts.drizzle !== true && !initialOpts.yes) { + const answers = await inquirer.prompt([ + { + type: 'confirm', + name: 'drizzle', + message: 'Use Drizzle?', + }, + ]) + command.setOptionValue('drizzle', answers.drizzle) + } + + const answers = await inquirer.prompt([ + { + type: 'confirm', + name: 'useDevBranch', + message: 'Use a development branch?', + }, + ]) + command.setOptionValue('useDevBranch', answers.useDevBranch) + + log(`Initializing a new database...`) + + try { + const siteEnv = await command.netlify.api.getEnvVar({ + accountId: siteInfo.account_id, + siteId: command.siteId, + key: 'NETLIFY_DATABASE_URL', + }) + + if (siteEnv.key === 'NETLIFY_DATABASE_URL') { + log(`Environment variable "NETLIFY_DATABASE_URL" already exists on site: ${siteInfo.name}`) + log(`You can run "netlify db status" to check the status for this site`) + return + } + } catch { + // no op, env var does not exist, so we just continue + } + + const hostSiteUrl = process.env.NEON_DATABASE_EXTENSION_HOST_SITE_URL ?? extension.hostSiteUrl + const initEndpoint = new URL('/cli-db-init', hostSiteUrl).toString() + + const headers = { + 'Content-Type': 'application/json', + 'nf-db-token': netlifyToken, + 'nf-db-site-id': command.siteId, + 'nf-db-account-id': siteInfo.account_id, + } + const req = await fetch(initEndpoint, { + method: 'POST', + headers, + }) + + if (!req.ok) { + throw new Error(`Failed to initialize DB: ${await req.text()}`) + } + + const res = (await req.json()) as { + code?: string + message?: string + } + + if (res.code !== 'DATABASE_INITIALIZED') { + throw new Error(`Failed to initialize DB: ${res.message ?? 'Unknown error'}`) + } + + if (opts.useDevBranch || (opts.yes && opts.useDevBranch !== false)) { + log(`Setting up local database...`) + const { uri, name } = await createDevBranch({ + headers, + command, + extension, + }) + command.setOptionValue('devBranchUri', uri) + log(`Created new development branch: ${name}`) + } + log(`Initializing drizzle...`) + + if (opts.drizzle || (opts.yes && opts.drizzle !== false)) { + await initDrizzle(command) + } + + log( + prettyjson.render({ + 'Current team': account.name, + 'Current site': siteInfo.name, + [`${extension.name} extension`]: 'installed', + Database: 'connected', + 'Site environment variable': 'NETLIFY_DATABASE_URL', + }), + ) + return +} diff --git a/src/commands/database/status.ts b/src/commands/database/status.ts new file mode 100644 index 00000000000..7cd3d052fcd --- /dev/null +++ b/src/commands/database/status.ts @@ -0,0 +1,66 @@ +import { OptionValues } from 'commander' +import { SiteInfo } from './database.js' +import BaseCommand from '../base-command.js' +import { getAccount, getExtension, getSiteConfiguration } from './utils.js' +import { NEON_DATABASE_EXTENSION_SLUG } from './constants.js' +import prettyjson from 'prettyjson' +import { chalk, log } from '../../utils/command-helpers.js' + +export const status = async (_options: OptionValues, command: BaseCommand) => { + const siteInfo = command.netlify.siteInfo as SiteInfo + if (!command.siteId) { + throw new Error(`The project must be linked with netlify link before initializing a database.`) + } + if (!siteInfo.account_id) { + throw new Error(`No account id found for site ${command.siteId}`) + } + if (!command.netlify.api.accessToken) { + throw new Error(`You must be logged in with netlify login to check the status of the database`) + } + const netlifyToken = command.netlify.api.accessToken.replace('Bearer ', '') + + const account = await getAccount(command, { accountId: siteInfo.account_id }) + + let siteEnv: Awaited> | undefined + try { + siteEnv = await command.netlify.api.getEnvVar({ + accountId: siteInfo.account_id, + siteId: command.siteId, + key: 'NETLIFY_DATABASE_URL', + }) + } catch { + // no-op, env var does not exist, so we just continue + } + + const extension = await getExtension({ + accountId: account.id, + token: netlifyToken, + slug: NEON_DATABASE_EXTENSION_SLUG, + }) + let siteConfig + try { + siteConfig = await getSiteConfiguration({ + siteId: command.siteId, + accountId: siteInfo.account_id, + slug: NEON_DATABASE_EXTENSION_SLUG, + token: netlifyToken, + }) + } catch { + // no-op, site config does not exist or extension not installed + } + + log( + prettyjson.render({ + 'Current team': account.name, + 'Current site': siteInfo.name, + [extension?.name ? `${extension.name} extension` : 'Database extension']: extension?.installedOnTeam + ? 'installed' + : chalk.red('not installed'), + // @ts-expect-error -- siteConfig is not typed + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + Database: siteConfig?.config?.neonProjectId ? 'connected' : chalk.red('not connected'), + 'Site environment variable': + siteEnv?.key === 'NETLIFY_DATABASE_URL' ? 'NETLIFY_DATABASE_URL' : chalk.red('NETLIFY_DATABASE_URL not set'), + }), + ) +} diff --git a/src/commands/database/utils.ts b/src/commands/database/utils.ts new file mode 100644 index 00000000000..a66e24b5591 --- /dev/null +++ b/src/commands/database/utils.ts @@ -0,0 +1,126 @@ +import fsPromises from 'fs/promises' +import fs from 'fs' +import inquirer from 'inquirer' + +import { JIGSAW_URL, NETLIFY_WEB_UI } from './constants.js' +import BaseCommand from '../base-command.js' +import { Extension } from './database.js' + +export const getExtension = async ({ accountId, token, slug }: { accountId: string; token: string; slug: string }) => { + const url = new URL('/.netlify/functions/fetch-extension', NETLIFY_WEB_UI) + url.searchParams.append('teamId', accountId) + url.searchParams.append('slug', slug) + + const extensionReq = await fetch(url.toString(), { + headers: { + Cookie: `_nf-auth=${token}`, + }, + }) + const extension = (await extensionReq.json()) as Extension | undefined + + return extension +} + +export const installExtension = async ({ + token, + accountId, + slug, + hostSiteUrl, +}: { + token: string + accountId: string + slug: string + hostSiteUrl: string +}) => { + const url = new URL('/.netlify/functions/install-extension', NETLIFY_WEB_UI) + const installExtensionResponse = await fetch(url.toString(), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Cookie: `_nf-auth=${token}`, + }, + body: JSON.stringify({ + teamId: accountId, + slug, + hostSiteUrl, + }), + }) + + if (!installExtensionResponse.ok) { + throw new Error(`Failed to install extension: ${slug}`) + } + + const installExtensionData = await installExtensionResponse.json() + return installExtensionData +} + +export const getSiteConfiguration = async ({ + siteId, + accountId, + token, + slug, +}: { + siteId: string + accountId: string + token: string + slug: string +}) => { + const url = new URL(`/team/${accountId}/integrations/${slug}/configuration/site/${siteId}`, JIGSAW_URL) + const siteConfigurationResponse = await fetch(url.toString(), { + headers: { + 'netlify-token': token, + }, + }) + if (!siteConfigurationResponse.ok) { + throw new Error(`Failed to fetch extension site configuration for ${siteId}. Is the extension installed?`) + } + + const siteConfiguration = await siteConfigurationResponse.json() + return siteConfiguration +} + +export const carefullyWriteFile = async (filePath: string, data: string, projectRoot: string) => { + if (fs.existsSync(filePath)) { + type Answers = { + overwrite: boolean + } + const answers = await inquirer.prompt([ + { + type: 'confirm', + name: 'overwrite', + message: `Overwrite existing file .${filePath.replace(projectRoot, '')}?`, + }, + ]) + if (answers.overwrite) { + await fsPromises.writeFile(filePath, data) + } + } else { + await fsPromises.writeFile(filePath, data) + } +} + +export const getAccount = async ( + command: BaseCommand, + { + accountId, + }: { + accountId: string + }, +) => { + let account: Awaited>[number] + try { + // @ts-expect-error -- TODO: fix the getAccount type in the openapi spec. It should not be an array of accounts, just one account. + account = await command.netlify.api.getAccount({ accountId }) + } catch (e) { + throw new Error(`Error getting account, make sure you are logged in with netlify login`, { + cause: e, + }) + } + if (!account.id || !account.name) { + throw new Error(`Error getting account, make sure you are logged in with netlify login`) + } + return account as { id: string; name: string } & Omit< + Awaited>[number], + 'id' | 'name' + > +} diff --git a/src/commands/main.ts b/src/commands/main.ts index 40f5eb45d06..3a78ab90c26 100644 --- a/src/commands/main.ts +++ b/src/commands/main.ts @@ -46,6 +46,7 @@ import { createSwitchCommand } from './switch/index.js' import { AddressInUseError } from './types.js' import { createUnlinkCommand } from './unlink/index.js' import { createWatchCommand } from './watch/index.js' +import { createDatabaseCommand } from './database/index.js' const SUGGESTION_TIMEOUT = 1e4 @@ -240,6 +241,7 @@ export const createMainCommand = (): BaseCommand => { createUnlinkCommand(program) createWatchCommand(program) createLogsCommand(program) + createDatabaseCommand(program) program.setAnalyticsPayload({ didEnableCompileCache })