From 5d42ca0b55372525300ce3e252b8f384cab4e084 Mon Sep 17 00:00:00 2001 From: Caleb Barnes Date: Wed, 19 Mar 2025 21:16:27 -0700 Subject: [PATCH 01/10] POC adding database init command / drizzle-kit as an external subcommand --- src/commands/database/database.ts | 295 ++++++++++++++++++++++++++++++ src/commands/database/index.ts | 1 + src/commands/database/utils.ts | 83 +++++++++ src/commands/main.ts | 2 + 4 files changed, 381 insertions(+) create mode 100644 src/commands/database/database.ts create mode 100644 src/commands/database/index.ts create mode 100644 src/commands/database/utils.ts diff --git a/src/commands/database/database.ts b/src/commands/database/database.ts new file mode 100644 index 00000000000..3217de97a70 --- /dev/null +++ b/src/commands/database/database.ts @@ -0,0 +1,295 @@ +import fs from 'fs' +import path from 'path' +import { OptionValues } from 'commander' +import BaseCommand from '../base-command.js' + +import openBrowser from '../../utils/open-browser.js' +import { getExtension, getExtensionInstallations, installExtension } from './utils.js' +import { getToken } from '../../utils/command-helpers.js' +import inquirer from 'inquirer' +import { NetlifyAPI } from 'netlify' +import { spawn } from 'child_process' + +const NETLIFY_DATABASE_EXTENSION_SLUG = '-94w9m6w-netlify-database-extension' + +const init = async (_options: OptionValues, command: BaseCommand) => { + process.env.UNSTABLE_NETLIFY_DATABASE_EXTENSION_HOST_SITE_URL = 'http://localhost:8989' + + if (!command.siteId) { + console.error(`The project must be linked with netlify link before initializing a database.`) + return + } + + const initialOpts = command.opts() + + const answers = await inquirer.prompt( + [ + { + type: 'confirm', + name: 'drizzle', + message: 'Use Drizzle?', + }, + ].filter((q) => !initialOpts[q.name]), + ) + + if (!initialOpts.drizzle) { + command.setOptionValue('drizzle', answers.drizzle) + } + const opts = command.opts() + + if (opts.drizzle && command.project.root) { + const drizzleConfigFilePath = path.resolve(command.project.root, 'drizzle.config.ts') + await carefullyWriteFile(drizzleConfigFilePath, drizzleConfig) + + fs.mkdirSync(path.resolve(command.project.root, 'db'), { recursive: true }) + const schemaFilePath = path.resolve(command.project.root, 'db/schema.ts') + await carefullyWriteFile(schemaFilePath, exampleDrizzleSchema) + + const dbIndexFilePath = path.resolve(command.project.root, 'db/index.ts') + await carefullyWriteFile(dbIndexFilePath, exampleDbIndex) + + console.log('Adding drizzle-kit and drizzle-orm to the project') + // install dev deps + const devDepProc = spawn( + command.project.packageManager?.installCommand ?? 'npm install', + ['drizzle-kit@latest', '-D'], + { + stdio: 'inherit', + shell: true, + }, + ) + devDepProc.on('exit', (code) => { + if (code === 0) { + // install deps + spawn(command.project.packageManager?.installCommand ?? 'npm install', ['drizzle-orm@latest'], { + stdio: 'inherit', + shell: true, + }) + } + }) + } + + let site: Awaited> + try { + // @ts-expect-error -- feature_flags is not in the types + site = await command.netlify.api.getSite({ siteId: command.siteId, feature_flags: 'cli' }) + } catch (e) { + console.error(`Error getting site, make sure you are logged in with netlify login`, e) + return + } + if (!site.account_id) { + console.error(`Error getting site, make sure you are logged in with netlify login`) + return + } + if (!command.netlify.api.accessToken) { + console.error(`You must be logged in with netlify login to initialize a database.`) + return + } + + const netlifyToken = command.netlify.api.accessToken.replace('Bearer ', '') + + const extension = await getExtension({ + accountId: site.account_id, + token: netlifyToken, + slug: NETLIFY_DATABASE_EXTENSION_SLUG, + }) + + if (!extension?.hostSiteUrl) { + throw new Error(`Failed to get extension host site url when installing extension`) + } + + const installations = await getExtensionInstallations({ + accountId: site.account_id, + siteId: command.siteId, + token: netlifyToken, + }) + const dbExtensionInstallation = ( + installations as { + integrationSlug: string + }[] + ).find((installation) => installation.integrationSlug === NETLIFY_DATABASE_EXTENSION_SLUG) + + if (!dbExtensionInstallation) { + console.log(`Netlify Database extension not installed on team ${site.account_id}, attempting to install now...`) + + const installed = await installExtension({ + accountId: site.account_id, + token: netlifyToken, + slug: NETLIFY_DATABASE_EXTENSION_SLUG, + hostSiteUrl: extension.hostSiteUrl ?? '', + }) + if (!installed) { + throw new Error(`Failed to install extension on team ${site.account_id}: ${NETLIFY_DATABASE_EXTENSION_SLUG}`) + } + console.log(`Netlify Database extension installed on team ${site.account_id}`) + } + + try { + const siteEnv = await command.netlify.api.getEnvVar({ + accountId: site.account_id, + siteId: command.siteId, + key: 'NETLIFY_DATABASE_URL', + }) + + if (siteEnv.key === 'NETLIFY_DATABASE_URL') { + console.error(`Database already initialized for site: ${command.siteId}, skipping.`) + return + } + } catch { + // no op, env var does not exist, so we just continue + } + + console.log('Initializing a new database for site:', command.siteId) + + const initEndpoint = new URL( + '/cli-db-init', + process.env.UNSTABLE_NETLIFY_DATABASE_EXTENSION_HOST_SITE_URL ?? extension.hostSiteUrl, + ).toString() + + const req = await fetch(initEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${netlifyToken}`, + 'x-nf-db-site-id': command.siteId, + 'x-nf-db-account-id': site.account_id, + }, + }) + + const res = await req.json() + console.log(res) + return +} + +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('--drizzle', 'Sets up drizzle-kit and drizzle-orm in your project') + .action(init) + + dbCommand + .command('drizzle-kit', 'TODO: write description for drizzle-kit command', { + executableFile: path.resolve(program.workingDir, './node_modules/drizzle-kit/bin.cjs'), + }) + .option('--open', 'when running drizzle-kit studio, open the browser to the studio url') + .hook('preSubcommand', async (thisCommand, actionCommand) => { + if (actionCommand.name() === 'drizzle-kit') { + // @ts-expect-error thisCommand is not assignable to BaseCommand + await drizzleKitPreAction(thisCommand) // set the NETLIFY_DATABASE_URL env var before drizzle-kit runs + } + }) + .allowUnknownOption() // allow unknown options to be passed through to drizzle-kit executable + + return dbCommand +} + +const drizzleKitPreAction = async (thisCommand: BaseCommand) => { + const opts = thisCommand.opts() + const workingDir = thisCommand.workingDir + const drizzleKitBinPath = path.resolve(workingDir, './node_modules/drizzle-kit/bin.cjs') + try { + fs.statSync(drizzleKitBinPath) + } catch { + console.error(`drizzle-kit not found in project's node modules, make sure you have installed drizzle-kit.`) + return + } + + const rawState = fs.readFileSync(path.resolve(workingDir, '.netlify/state.json'), 'utf8') + const state = JSON.parse(rawState) as { siteId?: string } | undefined + if (!state?.siteId) { + throw new Error(`No site id found in .netlify/state.json`) + } + + const [token] = await getToken() + if (!token) { + throw new Error(`No token found, please login with netlify login`) + } + const client = new NetlifyAPI(token) + let site + try { + site = await client.getSite({ siteId: state.siteId }) + } catch { + throw new Error(`No site found for site id ${state.siteId}`) + } + const accountId = site.account_id + if (!accountId) { + throw new Error(`No account id found for site ${state.siteId}`) + } + + let netlifyDatabaseEnv + try { + netlifyDatabaseEnv = await client.getEnvVar({ + siteId: state.siteId, + accountId, + key: 'NETLIFY_DATABASE_URL', + }) + } catch { + throw new Error( + `NETLIFY_DATABASE_URL environment variable not found on site ${state.siteId}. Run \`netlify db init\` first.`, + ) + } + + const NETLIFY_DATABASE_URL = netlifyDatabaseEnv.values?.find( + (val) => val.context === 'all' || val.context === 'dev', + )?.value + + if (!NETLIFY_DATABASE_URL) { + console.error(`NETLIFY_DATABASE_URL environment variable not found in project settings.`) + return + } + + if (typeof NETLIFY_DATABASE_URL === 'string') { + process.env.NETLIFY_DATABASE_URL = NETLIFY_DATABASE_URL + if (opts.open) { + await openBrowser({ url: 'https://local.drizzle.studio/', silentBrowserNoneError: true }) + } + } +} + +const drizzleConfig = `import { defineConfig } from 'drizzle-kit'; + +export default defineConfig({ + dialect: 'postgresql', + dbCredentials: { + url: process.env.NETLIFY_DATABASE_URL! + }, + schema: './db/schema.ts' +});` + +const exampleDrizzleSchema = `import { integer, pgTable, varchar, text } from 'drizzle-orm/pg-core'; + +export const post = pgTable('post', { + id: integer().primaryKey().generatedAlwaysAsIdentity(), + title: varchar({ length: 255 }).notNull(), + content: text().notNull().default('') +}); +` + +const exampleDbIndex = `import { drizzle } from 'lib/db'; +// import { drizzle } from '@netlify/database' +import * as schema from 'db/schema'; + +export const db = drizzle({ + schema +}); +` + +const carefullyWriteFile = async (filePath: string, data: string) => { + if (fs.existsSync(filePath)) { + const answers = await inquirer.prompt([ + { + type: 'confirm', + name: 'overwrite', + message: `Overwrite existing ${path.basename(filePath)}?`, + }, + ]) + if (answers.overwrite) { + fs.writeFileSync(filePath, data) + } + } else { + fs.writeFileSync(filePath, data) + } +} diff --git a/src/commands/database/index.ts b/src/commands/database/index.ts new file mode 100644 index 00000000000..1f7cf96d27e --- /dev/null +++ b/src/commands/database/index.ts @@ -0,0 +1 @@ +export { createDatabaseCommand as createDevCommand } from './database.js' diff --git a/src/commands/database/utils.ts b/src/commands/database/utils.ts new file mode 100644 index 00000000000..c9679750a88 --- /dev/null +++ b/src/commands/database/utils.ts @@ -0,0 +1,83 @@ +const JIGSAW_URL = 'https://jigsaw.services-prod.nsvcs.net' + +export const getExtensionInstallations = async ({ + siteId, + accountId, + token, +}: { + siteId: string + accountId: string + token: string +}) => { + const installationsResponse = await fetch( + `${JIGSAW_URL}/team/${encodeURIComponent(accountId)}/integrations/installations/${encodeURIComponent(siteId)}`, + { + headers: { + 'netlify-token': token, + }, + }, + ) + + if (!installationsResponse.ok) { + return new Response('Failed to fetch installed extensions for site', { + status: 500, + }) + } + + const installations = await installationsResponse.json() + // console.log('installations', installations) + return installations +} + +export const getExtension = async ({ accountId, token, slug }: { accountId: string; token: string; slug: string }) => { + const fetchExtensionUrl = new URL('/.netlify/functions/fetch-extension', 'https://app.netlify.com/') + fetchExtensionUrl.searchParams.append('teamId', accountId) + fetchExtensionUrl.searchParams.append('slug', slug) + + const extensionReq = await fetch(fetchExtensionUrl.toString(), { + headers: { + Cookie: `_nf-auth=${token}`, + }, + }) + const extension = (await extensionReq.json()) as + | { + hostSiteUrl?: string + } + | undefined + + return extension +} + +export const installExtension = async ({ + token, + accountId, + slug, + hostSiteUrl, +}: { + token: string + accountId: string + slug: string + hostSiteUrl: string +}) => { + const installExtensionResponse = await fetch(`https://app.netlify.com/.netlify/functions/install-extension`, { + 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() + console.log('installExtensionData', installExtensionData) + + return installExtensionData +} diff --git a/src/commands/main.ts b/src/commands/main.ts index 40f5eb45d06..0df211c3831 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/database.js' const SUGGESTION_TIMEOUT = 1e4 @@ -240,6 +241,7 @@ export const createMainCommand = (): BaseCommand => { createUnlinkCommand(program) createWatchCommand(program) createLogsCommand(program) + createDatabaseCommand(program) program.setAnalyticsPayload({ didEnableCompileCache }) From 676f317f52fb38787bc0767363bce354f550e8e2 Mon Sep 17 00:00:00 2001 From: Caleb Barnes Date: Wed, 19 Mar 2025 21:21:21 -0700 Subject: [PATCH 02/10] cleanup --- src/commands/database/database.ts | 41 +++++++++---------------------- src/commands/database/utils.ts | 21 ++++++++++++++++ 2 files changed, 32 insertions(+), 30 deletions(-) diff --git a/src/commands/database/database.ts b/src/commands/database/database.ts index 3217de97a70..3ed59c4a0d5 100644 --- a/src/commands/database/database.ts +++ b/src/commands/database/database.ts @@ -4,7 +4,7 @@ import { OptionValues } from 'commander' import BaseCommand from '../base-command.js' import openBrowser from '../../utils/open-browser.js' -import { getExtension, getExtensionInstallations, installExtension } from './utils.js' +import { carefullyWriteFile, getExtension, getExtensionInstallations, installExtension } from './utils.js' import { getToken } from '../../utils/command-helpers.js' import inquirer from 'inquirer' import { NetlifyAPI } from 'netlify' @@ -69,25 +69,24 @@ const init = async (_options: OptionValues, command: BaseCommand) => { }) } + if (!command.netlify.api.accessToken) { + throw new Error(`No access token found, please login with netlify login`) + } + let site: Awaited> try { // @ts-expect-error -- feature_flags is not in the types site = await command.netlify.api.getSite({ siteId: command.siteId, feature_flags: 'cli' }) } catch (e) { - console.error(`Error getting site, make sure you are logged in with netlify login`, e) - return + throw new Error(`Error getting site, make sure you are logged in with netlify login`, { + cause: e, + }) } if (!site.account_id) { - console.error(`Error getting site, make sure you are logged in with netlify login`) - return - } - if (!command.netlify.api.accessToken) { - console.error(`You must be logged in with netlify login to initialize a database.`) - return + throw new Error(`No account id found for site ${command.siteId}`) } const netlifyToken = command.netlify.api.accessToken.replace('Bearer ', '') - const extension = await getExtension({ accountId: site.account_id, token: netlifyToken, @@ -132,14 +131,13 @@ const init = async (_options: OptionValues, command: BaseCommand) => { }) if (siteEnv.key === 'NETLIFY_DATABASE_URL') { - console.error(`Database already initialized for site: ${command.siteId}, skipping.`) - return + throw new Error(`Database already initialized for site: ${command.siteId}`) } } catch { // no op, env var does not exist, so we just continue } - console.log('Initializing a new database for site:', command.siteId) + console.log('Initializing a new database for site: ', command.siteId) const initEndpoint = new URL( '/cli-db-init', @@ -276,20 +274,3 @@ export const db = drizzle({ schema }); ` - -const carefullyWriteFile = async (filePath: string, data: string) => { - if (fs.existsSync(filePath)) { - const answers = await inquirer.prompt([ - { - type: 'confirm', - name: 'overwrite', - message: `Overwrite existing ${path.basename(filePath)}?`, - }, - ]) - if (answers.overwrite) { - fs.writeFileSync(filePath, data) - } - } else { - fs.writeFileSync(filePath, data) - } -} diff --git a/src/commands/database/utils.ts b/src/commands/database/utils.ts index c9679750a88..39b089226d7 100644 --- a/src/commands/database/utils.ts +++ b/src/commands/database/utils.ts @@ -1,3 +1,7 @@ +import fs from 'fs' +import inquirer from 'inquirer' +import path from 'path' + const JIGSAW_URL = 'https://jigsaw.services-prod.nsvcs.net' export const getExtensionInstallations = async ({ @@ -81,3 +85,20 @@ export const installExtension = async ({ return installExtensionData } + +export const carefullyWriteFile = async (filePath: string, data: string) => { + if (fs.existsSync(filePath)) { + const answers = await inquirer.prompt([ + { + type: 'confirm', + name: 'overwrite', + message: `Overwrite existing ${path.basename(filePath)}?`, + }, + ]) + if (answers.overwrite) { + fs.writeFileSync(filePath, data) + } + } else { + fs.writeFileSync(filePath, data) + } +} From 79d15c579f395b308da10a9e4dcb4fba6e31c9f6 Mon Sep 17 00:00:00 2001 From: Caleb Barnes Date: Mon, 24 Mar 2025 09:34:02 -0700 Subject: [PATCH 03/10] cleanup --- src/commands/database/database.ts | 150 ++---------------------------- src/commands/database/drizzle.ts | 68 ++++++++++++++ src/commands/database/index.ts | 2 +- src/commands/database/utils.ts | 2 +- src/commands/main.ts | 2 +- 5 files changed, 77 insertions(+), 147 deletions(-) create mode 100644 src/commands/database/drizzle.ts diff --git a/src/commands/database/database.ts b/src/commands/database/database.ts index 3ed59c4a0d5..e96e7ca59b1 100644 --- a/src/commands/database/database.ts +++ b/src/commands/database/database.ts @@ -1,14 +1,8 @@ -import fs from 'fs' -import path from 'path' import { OptionValues } from 'commander' -import BaseCommand from '../base-command.js' - -import openBrowser from '../../utils/open-browser.js' -import { carefullyWriteFile, getExtension, getExtensionInstallations, installExtension } from './utils.js' -import { getToken } from '../../utils/command-helpers.js' import inquirer from 'inquirer' -import { NetlifyAPI } from 'netlify' -import { spawn } from 'child_process' +import BaseCommand from '../base-command.js' +import { getExtension, getExtensionInstallations, installExtension } from './utils.js' +import { initDrizzle } from './drizzle.js' const NETLIFY_DATABASE_EXTENSION_SLUG = '-94w9m6w-netlify-database-extension' @@ -36,37 +30,8 @@ const init = async (_options: OptionValues, command: BaseCommand) => { command.setOptionValue('drizzle', answers.drizzle) } const opts = command.opts() - if (opts.drizzle && command.project.root) { - const drizzleConfigFilePath = path.resolve(command.project.root, 'drizzle.config.ts') - await carefullyWriteFile(drizzleConfigFilePath, drizzleConfig) - - fs.mkdirSync(path.resolve(command.project.root, 'db'), { recursive: true }) - const schemaFilePath = path.resolve(command.project.root, 'db/schema.ts') - await carefullyWriteFile(schemaFilePath, exampleDrizzleSchema) - - const dbIndexFilePath = path.resolve(command.project.root, 'db/index.ts') - await carefullyWriteFile(dbIndexFilePath, exampleDbIndex) - - console.log('Adding drizzle-kit and drizzle-orm to the project') - // install dev deps - const devDepProc = spawn( - command.project.packageManager?.installCommand ?? 'npm install', - ['drizzle-kit@latest', '-D'], - { - stdio: 'inherit', - shell: true, - }, - ) - devDepProc.on('exit', (code) => { - if (code === 0) { - // install deps - spawn(command.project.packageManager?.installCommand ?? 'npm install', ['drizzle-orm@latest'], { - stdio: 'inherit', - shell: true, - }) - } - }) + await initDrizzle(command) } if (!command.netlify.api.accessToken) { @@ -131,7 +96,8 @@ const init = async (_options: OptionValues, command: BaseCommand) => { }) if (siteEnv.key === 'NETLIFY_DATABASE_URL') { - throw new Error(`Database already initialized for site: ${command.siteId}`) + console.error(`Database already initialized for site: ${command.siteId}`) + return } } catch { // no op, env var does not exist, so we just continue @@ -168,109 +134,5 @@ export const createDatabaseCommand = (program: BaseCommand) => { .option('--drizzle', 'Sets up drizzle-kit and drizzle-orm in your project') .action(init) - dbCommand - .command('drizzle-kit', 'TODO: write description for drizzle-kit command', { - executableFile: path.resolve(program.workingDir, './node_modules/drizzle-kit/bin.cjs'), - }) - .option('--open', 'when running drizzle-kit studio, open the browser to the studio url') - .hook('preSubcommand', async (thisCommand, actionCommand) => { - if (actionCommand.name() === 'drizzle-kit') { - // @ts-expect-error thisCommand is not assignable to BaseCommand - await drizzleKitPreAction(thisCommand) // set the NETLIFY_DATABASE_URL env var before drizzle-kit runs - } - }) - .allowUnknownOption() // allow unknown options to be passed through to drizzle-kit executable - return dbCommand } - -const drizzleKitPreAction = async (thisCommand: BaseCommand) => { - const opts = thisCommand.opts() - const workingDir = thisCommand.workingDir - const drizzleKitBinPath = path.resolve(workingDir, './node_modules/drizzle-kit/bin.cjs') - try { - fs.statSync(drizzleKitBinPath) - } catch { - console.error(`drizzle-kit not found in project's node modules, make sure you have installed drizzle-kit.`) - return - } - - const rawState = fs.readFileSync(path.resolve(workingDir, '.netlify/state.json'), 'utf8') - const state = JSON.parse(rawState) as { siteId?: string } | undefined - if (!state?.siteId) { - throw new Error(`No site id found in .netlify/state.json`) - } - - const [token] = await getToken() - if (!token) { - throw new Error(`No token found, please login with netlify login`) - } - const client = new NetlifyAPI(token) - let site - try { - site = await client.getSite({ siteId: state.siteId }) - } catch { - throw new Error(`No site found for site id ${state.siteId}`) - } - const accountId = site.account_id - if (!accountId) { - throw new Error(`No account id found for site ${state.siteId}`) - } - - let netlifyDatabaseEnv - try { - netlifyDatabaseEnv = await client.getEnvVar({ - siteId: state.siteId, - accountId, - key: 'NETLIFY_DATABASE_URL', - }) - } catch { - throw new Error( - `NETLIFY_DATABASE_URL environment variable not found on site ${state.siteId}. Run \`netlify db init\` first.`, - ) - } - - const NETLIFY_DATABASE_URL = netlifyDatabaseEnv.values?.find( - (val) => val.context === 'all' || val.context === 'dev', - )?.value - - if (!NETLIFY_DATABASE_URL) { - console.error(`NETLIFY_DATABASE_URL environment variable not found in project settings.`) - return - } - - if (typeof NETLIFY_DATABASE_URL === 'string') { - process.env.NETLIFY_DATABASE_URL = NETLIFY_DATABASE_URL - if (opts.open) { - await openBrowser({ url: 'https://local.drizzle.studio/', silentBrowserNoneError: true }) - } - } -} - -const drizzleConfig = `import { defineConfig } from 'drizzle-kit'; - -export default defineConfig({ - dialect: 'postgresql', - dbCredentials: { - url: process.env.NETLIFY_DATABASE_URL! - }, - schema: './db/schema.ts' -});` - -const exampleDrizzleSchema = `import { integer, pgTable, varchar, text } from 'drizzle-orm/pg-core'; - -export const post = pgTable('post', { - id: integer().primaryKey().generatedAlwaysAsIdentity(), - title: varchar({ length: 255 }).notNull(), - content: text().notNull().default('') -}); -` - -const exampleDbIndex = `import { drizzle } from 'lib/db'; -// import { drizzle } from '@netlify/database' -import * as schema from 'db/schema'; - -export const db = drizzle({ - schema -}); -` diff --git a/src/commands/database/drizzle.ts b/src/commands/database/drizzle.ts new file mode 100644 index 00000000000..e600d657424 --- /dev/null +++ b/src/commands/database/drizzle.ts @@ -0,0 +1,68 @@ +import { spawn } from 'child_process' +import { carefullyWriteFile } from './utils.js' +import BaseCommand from '../base-command.js' +import path from 'path' +import fs from 'fs' + +export const initDrizzle = async (command: BaseCommand) => { + if (!command.project.root) { + throw new Error('Failed to initialize Drizzle in project. Project root not found.') + } + const drizzleConfigFilePath = path.resolve(command.project.root, 'drizzle.config.ts') + await carefullyWriteFile(drizzleConfigFilePath, drizzleConfig) + + fs.mkdirSync(path.resolve(command.project.root, 'db'), { recursive: true }) + const schemaFilePath = path.resolve(command.project.root, 'db/schema.ts') + await carefullyWriteFile(schemaFilePath, exampleDrizzleSchema) + + const dbIndexFilePath = path.resolve(command.project.root, 'db/index.ts') + await carefullyWriteFile(dbIndexFilePath, exampleDbIndex) + + console.log('Adding drizzle-kit and drizzle-orm to the project') + // install dev deps + const devDepProc = spawn( + command.project.packageManager?.installCommand ?? 'npm install', + ['drizzle-kit@latest', '-D'], + { + stdio: 'inherit', + shell: true, + }, + ) + devDepProc.on('exit', (code) => { + if (code === 0) { + // install deps + spawn(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' +});` + +const exampleDrizzleSchema = `import { integer, pgTable, varchar, text } from 'drizzle-orm/pg-core'; + +export const post = pgTable('post', { + id: integer().primaryKey().generatedAlwaysAsIdentity(), + title: varchar({ length: 255 }).notNull(), + content: text().notNull().default('') +}); +` + +const exampleDbIndex = `import { drizzle } from 'lib/db'; +// import { drizzle } from '@netlify/database' +import * as schema from 'db/schema'; + +export const db = drizzle({ + schema +}); +` diff --git a/src/commands/database/index.ts b/src/commands/database/index.ts index 1f7cf96d27e..27d2ca25f54 100644 --- a/src/commands/database/index.ts +++ b/src/commands/database/index.ts @@ -1 +1 @@ -export { createDatabaseCommand as createDevCommand } from './database.js' +export { createDatabaseCommand } from './database.js' diff --git a/src/commands/database/utils.ts b/src/commands/database/utils.ts index 39b089226d7..76fdce5109f 100644 --- a/src/commands/database/utils.ts +++ b/src/commands/database/utils.ts @@ -29,7 +29,7 @@ export const getExtensionInstallations = async ({ } const installations = await installationsResponse.json() - // console.log('installations', installations) + // console.log('installations', installations return installations } diff --git a/src/commands/main.ts b/src/commands/main.ts index 0df211c3831..3a78ab90c26 100644 --- a/src/commands/main.ts +++ b/src/commands/main.ts @@ -46,7 +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/database.js' +import { createDatabaseCommand } from './database/index.js' const SUGGESTION_TIMEOUT = 1e4 From d20528d38d8fcc95da5a5a6f9010dcd03a183891 Mon Sep 17 00:00:00 2001 From: Caleb Barnes Date: Mon, 24 Mar 2025 11:08:35 -0700 Subject: [PATCH 04/10] fix awaiting drizzle deps installing / add status check and getSiteConfiguration --- src/commands/database/database.ts | 90 +++++++++++++++++++++++++++---- src/commands/database/drizzle.ts | 67 +++++++++++++++++------ src/commands/database/utils.ts | 33 +++++++++++- 3 files changed, 160 insertions(+), 30 deletions(-) diff --git a/src/commands/database/database.ts b/src/commands/database/database.ts index e96e7ca59b1..c61cacc7b9b 100644 --- a/src/commands/database/database.ts +++ b/src/commands/database/database.ts @@ -1,7 +1,7 @@ import { OptionValues } from 'commander' import inquirer from 'inquirer' import BaseCommand from '../base-command.js' -import { getExtension, getExtensionInstallations, installExtension } from './utils.js' +import { getExtension, getExtensionInstallations, getSiteConfiguration, installExtension } from './utils.js' import { initDrizzle } from './drizzle.js' const NETLIFY_DATABASE_EXTENSION_SLUG = '-94w9m6w-netlify-database-extension' @@ -37,7 +37,8 @@ const init = async (_options: OptionValues, command: BaseCommand) => { if (!command.netlify.api.accessToken) { throw new Error(`No access token found, please login with netlify login`) } - + console.log(`Initializing a new database for site: ${command.siteId} +Please wait...`) let site: Awaited> try { // @ts-expect-error -- feature_flags is not in the types @@ -76,16 +77,27 @@ const init = async (_options: OptionValues, command: BaseCommand) => { if (!dbExtensionInstallation) { console.log(`Netlify Database extension not installed on team ${site.account_id}, attempting to install now...`) - const installed = await installExtension({ - accountId: site.account_id, - token: netlifyToken, - slug: NETLIFY_DATABASE_EXTENSION_SLUG, - hostSiteUrl: extension.hostSiteUrl ?? '', - }) - if (!installed) { - throw new Error(`Failed to install extension on team ${site.account_id}: ${NETLIFY_DATABASE_EXTENSION_SLUG}`) + const answers = await inquirer.prompt([ + { + type: 'confirm', + name: 'installExtension', + message: `Netlify Database extension is not installed on team ${site.account_id}, would you like to install it now?`, + }, + ]) + if (answers.installExtension) { + const installed = await installExtension({ + accountId: site.account_id, + token: netlifyToken, + slug: NETLIFY_DATABASE_EXTENSION_SLUG, + hostSiteUrl: extension.hostSiteUrl ?? '', + }) + if (!installed) { + throw new Error(`Failed to install extension on team ${site.account_id}: ${NETLIFY_DATABASE_EXTENSION_SLUG}`) + } + console.log(`Netlify Database extension installed on team ${site.account_id}`) + } else { + return } - console.log(`Netlify Database extension installed on team ${site.account_id}`) } try { @@ -125,6 +137,60 @@ const init = async (_options: OptionValues, command: BaseCommand) => { return } +const status = async (_options: OptionValues, command: BaseCommand) => { + if (!command.siteId) { + console.error(`The project must be linked with netlify link before initializing a database.`) + return + } + // check if this site has a db initialized + const site = await command.netlify.api.getSite({ siteId: command.siteId }) + if (!site.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 extensionInstallation = await getExtensionInstallations({ + accountId: site.account_id, + siteId: command.siteId, + token: netlifyToken, + }) + + if (!extensionInstallation) { + console.log(`Netlify Database extension not installed on team ${site.account_id}`) + return + } + + const siteConfig = await getSiteConfiguration({ + siteId: command.siteId, + accountId: site.account_id, + slug: NETLIFY_DATABASE_EXTENSION_SLUG, + token: netlifyToken, + }) + + if (!siteConfig) { + throw new Error(`Failed to get site configuration for site ${command.siteId}`) + } + try { + const siteEnv = await command.netlify.api.getEnvVar({ + accountId: site.account_id, + siteId: command.siteId, + key: 'NETLIFY_DATABASE_URL', + }) + + if (siteEnv.key === 'NETLIFY_DATABASE_URL') { + console.log(`Database initialized for site: ${command.siteId}`) + return + } + } catch { + throw new Error( + `Database not initialized for site: ${command.siteId}. +Run 'netlify db init' to initialize a database`, + ) + } +} + export const createDatabaseCommand = (program: BaseCommand) => { const dbCommand = program.command('db').alias('database').description(`TODO: write description for database command`) @@ -134,5 +200,7 @@ export const createDatabaseCommand = (program: BaseCommand) => { .option('--drizzle', 'Sets up drizzle-kit and drizzle-orm in your project') .action(init) + dbCommand.command('status').description('Check the status of the database').action(status) + return dbCommand } diff --git a/src/commands/database/drizzle.ts b/src/commands/database/drizzle.ts index e600d657424..f06e3333ad6 100644 --- a/src/commands/database/drizzle.ts +++ b/src/commands/database/drizzle.ts @@ -2,7 +2,8 @@ import { spawn } from 'child_process' import { carefullyWriteFile } from './utils.js' import BaseCommand from '../base-command.js' import path from 'path' -import fs from 'fs' +import fs from 'fs/promises' +import inquirer from 'inquirer' export const initDrizzle = async (command: BaseCommand) => { if (!command.project.root) { @@ -11,31 +12,43 @@ export const initDrizzle = async (command: BaseCommand) => { const drizzleConfigFilePath = path.resolve(command.project.root, 'drizzle.config.ts') await carefullyWriteFile(drizzleConfigFilePath, drizzleConfig) - fs.mkdirSync(path.resolve(command.project.root, 'db'), { recursive: true }) + await fs.mkdir(path.resolve(command.project.root, 'db'), { recursive: true }) const schemaFilePath = path.resolve(command.project.root, 'db/schema.ts') await carefullyWriteFile(schemaFilePath, exampleDrizzleSchema) const dbIndexFilePath = path.resolve(command.project.root, 'db/index.ts') await carefullyWriteFile(dbIndexFilePath, exampleDbIndex) - console.log('Adding drizzle-kit and drizzle-orm to the project') - // install dev deps - const devDepProc = spawn( - command.project.packageManager?.installCommand ?? 'npm install', - ['drizzle-kit@latest', '-D'], + const packageJsonPath = path.resolve(command.project.root, 'package.json') + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf-8')) + + const answers = await inquirer.prompt([ { - stdio: 'inherit', - shell: true, + type: 'confirm', + name: 'updatePackageJson', + message: `Add drizzle db commands to package.json?`, }, - ) - devDepProc.on('exit', (code) => { - if (code === 0) { - // install deps - spawn(command.project.packageManager?.installCommand ?? 'npm install', ['drizzle-orm@latest'], { - stdio: 'inherit', - shell: true, - }) + ]) + if (answers.updatePackageJson) { + // 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, } + await fs.writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2)) + } + + await spawnAsync(command.project.packageManager?.installCommand ?? 'npm install', ['drizzle-kit@latest', '-D'], { + stdio: 'inherit', + shell: true, + }) + + await spawnAsync(command.project.packageManager?.installCommand ?? 'npm install', ['drizzle-orm@latest'], { + stdio: 'inherit', + shell: true, }) } @@ -66,3 +79,23 @@ export const db = drizzle({ schema }); ` + +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', +} + +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)) + }) + }) +} diff --git a/src/commands/database/utils.ts b/src/commands/database/utils.ts index 76fdce5109f..906f95f63c0 100644 --- a/src/commands/database/utils.ts +++ b/src/commands/database/utils.ts @@ -1,3 +1,4 @@ +import fsPromises from 'fs/promises' import fs from 'fs' import inquirer from 'inquirer' import path from 'path' @@ -96,9 +97,37 @@ export const carefullyWriteFile = async (filePath: string, data: string) => { }, ]) if (answers.overwrite) { - fs.writeFileSync(filePath, data) + await fsPromises.writeFile(filePath, data) } } else { - fs.writeFileSync(filePath, data) + await fsPromises.writeFile(filePath, data) } } + +export const getSiteConfiguration = async ({ + siteId, + accountId, + token, + slug, +}: { + siteId: string + accountId: string + token: string + slug: string +}) => { + const siteConfigurationResponse = await fetch( + `${JIGSAW_URL}/team/${accountId}/integrations/${slug}/configuration/site/${siteId}`, + { + 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 +} From 540ddf06bf890ebbc77e1050abf9ce8ce560d1ea Mon Sep 17 00:00:00 2001 From: Caleb Barnes Date: Tue, 25 Mar 2025 09:12:59 -0700 Subject: [PATCH 05/10] fix request headers for init endpoint / misc cleanup --- src/commands/database/database.ts | 70 ++++++++++++++----------------- src/commands/database/drizzle.ts | 49 ++++++++++++++-------- src/commands/database/utils.ts | 1 + 3 files changed, 63 insertions(+), 57 deletions(-) diff --git a/src/commands/database/database.ts b/src/commands/database/database.ts index c61cacc7b9b..6fa4b62bb63 100644 --- a/src/commands/database/database.ts +++ b/src/commands/database/database.ts @@ -4,11 +4,9 @@ import BaseCommand from '../base-command.js' import { getExtension, getExtensionInstallations, getSiteConfiguration, installExtension } from './utils.js' import { initDrizzle } from './drizzle.js' -const NETLIFY_DATABASE_EXTENSION_SLUG = '-94w9m6w-netlify-database-extension' +const NETLIFY_DATABASE_EXTENSION_SLUG = '7jjmnqyo-netlify-neon' const init = async (_options: OptionValues, command: BaseCommand) => { - process.env.UNSTABLE_NETLIFY_DATABASE_EXTENSION_HOST_SITE_URL = 'http://localhost:8989' - if (!command.siteId) { console.error(`The project must be linked with netlify link before initializing a database.`) return @@ -16,18 +14,20 @@ const init = async (_options: OptionValues, command: BaseCommand) => { const initialOpts = command.opts() - const answers = await inquirer.prompt( - [ - { - type: 'confirm', - name: 'drizzle', - message: 'Use Drizzle?', - }, - ].filter((q) => !initialOpts[q.name]), - ) + if (initialOpts.drizzle !== false) { + const answers = await inquirer.prompt( + [ + { + type: 'confirm', + name: 'drizzle', + message: 'Use Drizzle?', + }, + ].filter((q) => !initialOpts[q.name]), + ) - if (!initialOpts.drizzle) { - command.setOptionValue('drizzle', answers.drizzle) + if (!initialOpts.drizzle) { + command.setOptionValue('drizzle', answers.drizzle) + } } const opts = command.opts() if (opts.drizzle && command.project.root) { @@ -37,8 +37,7 @@ const init = async (_options: OptionValues, command: BaseCommand) => { if (!command.netlify.api.accessToken) { throw new Error(`No access token found, please login with netlify login`) } - console.log(`Initializing a new database for site: ${command.siteId} -Please wait...`) + let site: Awaited> try { // @ts-expect-error -- feature_flags is not in the types @@ -48,10 +47,14 @@ Please wait...`) cause: e, }) } + // console.log('site', site) if (!site.account_id) { throw new Error(`No account id found for site ${command.siteId}`) } + console.log(`Initializing a new database for site: ${command.siteId} on account ${site.account_id} + Please wait...`) + const netlifyToken = command.netlify.api.accessToken.replace('Bearer ', '') const extension = await getExtension({ accountId: site.account_id, @@ -63,20 +66,7 @@ Please wait...`) throw new Error(`Failed to get extension host site url when installing extension`) } - const installations = await getExtensionInstallations({ - accountId: site.account_id, - siteId: command.siteId, - token: netlifyToken, - }) - const dbExtensionInstallation = ( - installations as { - integrationSlug: string - }[] - ).find((installation) => installation.integrationSlug === NETLIFY_DATABASE_EXTENSION_SLUG) - - if (!dbExtensionInstallation) { - console.log(`Netlify Database extension not installed on team ${site.account_id}, attempting to install now...`) - + if (!extension.installedOnTeam) { const answers = await inquirer.prompt([ { type: 'confirm', @@ -115,23 +105,24 @@ Please wait...`) // no op, env var does not exist, so we just continue } - console.log('Initializing a new database for site: ', command.siteId) - - const initEndpoint = new URL( - '/cli-db-init', - process.env.UNSTABLE_NETLIFY_DATABASE_EXTENSION_HOST_SITE_URL ?? extension.hostSiteUrl, - ).toString() + const extensionSiteUrl = process.env.UNSTABLE_NETLIFY_DATABASE_EXTENSION_HOST_SITE_URL ?? extension.hostSiteUrl + const initEndpoint = new URL('/cli-db-init', extensionSiteUrl).toString() + console.log('initEndpoint', initEndpoint) const req = await fetch(initEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', - Authorization: `Bearer ${netlifyToken}`, - 'x-nf-db-site-id': command.siteId, - 'x-nf-db-account-id': site.account_id, + 'nf-db-token': netlifyToken, + 'nf-db-site-id': command.siteId, + 'nf-db-account-id': site.account_id, }, }) + if (!req.ok) { + throw new Error(`Failed to initialize DB: ${await req.text()}`) + } + const res = await req.json() console.log(res) return @@ -198,6 +189,7 @@ export const createDatabaseCommand = (program: BaseCommand) => { .command('init') .description('Initialize a new database') .option('--drizzle', 'Sets up drizzle-kit and drizzle-orm in your project') + .option('--no-drizzle', 'Skips drizzle') .action(init) dbCommand.command('status').description('Check the status of the database').action(status) diff --git a/src/commands/database/drizzle.ts b/src/commands/database/drizzle.ts index f06e3333ad6..920f7954b87 100644 --- a/src/commands/database/drizzle.ts +++ b/src/commands/database/drizzle.ts @@ -41,15 +41,26 @@ export const initDrizzle = async (command: BaseCommand) => { await fs.writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2)) } - await spawnAsync(command.project.packageManager?.installCommand ?? 'npm install', ['drizzle-kit@latest', '-D'], { - stdio: 'inherit', - shell: true, - }) - - await spawnAsync(command.project.packageManager?.installCommand ?? 'npm install', ['drizzle-orm@latest'], { - stdio: 'inherit', - shell: true, - }) + // 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, + }) + } else { + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions, @typescript-eslint/no-unsafe-member-access + console.log(`drizzle-kit already installed... Using version ${packageJson?.devDependencies?.['drizzle-kit']}`) + } + // 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, + }) + } else { + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions, @typescript-eslint/no-unsafe-member-access + console.log(`drizzle-orm already installed... Using version ${packageJson?.dependencies?.['drizzle-orm']}`) + } } const drizzleConfig = `import { defineConfig } from 'drizzle-kit'; @@ -59,31 +70,33 @@ export default defineConfig({ dbCredentials: { url: process.env.NETLIFY_DATABASE_URL! }, - schema: './db/schema.ts' + schema: './db/schema.ts', + out: './migrations' });` const exampleDrizzleSchema = `import { integer, pgTable, varchar, text } from 'drizzle-orm/pg-core'; -export const post = pgTable('post', { +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'; -const exampleDbIndex = `import { drizzle } from 'lib/db'; -// import { drizzle } from '@netlify/database' import * as schema from 'db/schema'; export const db = drizzle({ - schema -}); -` + 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 => { diff --git a/src/commands/database/utils.ts b/src/commands/database/utils.ts index 906f95f63c0..95e35e59cb9 100644 --- a/src/commands/database/utils.ts +++ b/src/commands/database/utils.ts @@ -47,6 +47,7 @@ export const getExtension = async ({ accountId, token, slug }: { accountId: stri const extension = (await extensionReq.json()) as | { hostSiteUrl?: string + installedOnTeam: boolean } | undefined From a492566feb84908cdaec97a532c5d55168571395 Mon Sep 17 00:00:00 2001 From: Caleb Barnes Date: Tue, 25 Mar 2025 09:16:59 -0700 Subject: [PATCH 06/10] only inquirer.prompt if initialOpts.drizzle is not explicitly passed as true or false --- src/commands/database/database.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/database/database.ts b/src/commands/database/database.ts index 6fa4b62bb63..002317fa0c6 100644 --- a/src/commands/database/database.ts +++ b/src/commands/database/database.ts @@ -14,7 +14,7 @@ const init = async (_options: OptionValues, command: BaseCommand) => { const initialOpts = command.opts() - if (initialOpts.drizzle !== false) { + if (initialOpts.drizzle !== false && initialOpts.drizzle !== true) { const answers = await inquirer.prompt( [ { From dd172a58c11fc81b227798c48d802a3447478cbd Mon Sep 17 00:00:00 2001 From: Caleb Barnes Date: Tue, 25 Mar 2025 11:49:43 -0700 Subject: [PATCH 07/10] move urls to constants with env overrides / cleanup logging / remove unused function / add --yes option to use defaults and --overwrite to overwrite files --- src/commands/database/constants.ts | 3 + src/commands/database/database.ts | 237 ++++++++++++++++++----------- src/commands/database/drizzle.ts | 62 ++++---- src/commands/database/utils.ts | 125 ++++++++------- 4 files changed, 248 insertions(+), 179 deletions(-) create mode 100644 src/commands/database/constants.ts 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 index 002317fa0c6..bc023c9a3c3 100644 --- a/src/commands/database/database.ts +++ b/src/commands/database/database.ts @@ -1,12 +1,23 @@ import { OptionValues } from 'commander' import inquirer from 'inquirer' import BaseCommand from '../base-command.js' -import { getExtension, getExtensionInstallations, getSiteConfiguration, installExtension } from './utils.js' +import { getAccount, getExtension, getSiteConfiguration, installExtension } from './utils.js' import { initDrizzle } from './drizzle.js' +import { NEON_DATABASE_EXTENSION_SLUG } from './constants.js' +import prettyjson from 'prettyjson' +import { chalk, log } from '../../utils/command-helpers.js' -const NETLIFY_DATABASE_EXTENSION_SLUG = '7jjmnqyo-netlify-neon' +type SiteInfo = { + id: string + name: string + account_id: string + admin_url: string + url: string + ssl_url: string +} 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 @@ -14,108 +25,132 @@ const init = async (_options: OptionValues, command: BaseCommand) => { const initialOpts = command.opts() - if (initialOpts.drizzle !== false && initialOpts.drizzle !== true) { - const answers = await inquirer.prompt( - [ - { - type: 'confirm', - name: 'drizzle', - message: 'Use Drizzle?', - }, - ].filter((q) => !initialOpts[q.name]), - ) - - if (!initialOpts.drizzle) { - command.setOptionValue('drizzle', answers.drizzle) + /** + * 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) { + type Answers = { + drizzle: boolean } + const answers = await inquirer.prompt([ + { + type: 'confirm', + name: 'drizzle', + message: 'Use Drizzle?', + }, + ]) + command.setOptionValue('drizzle', answers.drizzle) } - const opts = command.opts() - if (opts.drizzle && command.project.root) { + + const opts = command.opts<{ + drizzle?: boolean | undefined + /** + * Skip prompts and use default values (answer yes to all prompts) + */ + yes?: true | undefined + }>() + + if (opts.drizzle || (opts.yes && opts.drizzle !== false)) { await initDrizzle(command) } if (!command.netlify.api.accessToken) { - throw new Error(`No access token found, please login with netlify login`) + throw new Error(`Please login with netlify login before running this command`) } - let site: Awaited> - try { - // @ts-expect-error -- feature_flags is not in the types - site = await command.netlify.api.getSite({ siteId: command.siteId, feature_flags: 'cli' }) - } catch (e) { - throw new Error(`Error getting site, make sure you are logged in with netlify login`, { - cause: e, - }) - } - // console.log('site', site) - if (!site.account_id) { - throw new Error(`No account id found for site ${command.siteId}`) + // let site: Awaited> + // try { + // site = await command.netlify.api.getSite({ + // siteId: command.siteId, + // // @ts-expect-error -- feature_flags is not in the types + // feature_flags: 'cli', + // }) + // } catch (e) { + // throw new Error(`Error getting site, make sure you are logged in with netlify login`, { + // cause: e, + // }) + // } + if (!siteInfo.account_id || !siteInfo.name) { + throw new Error(`Error getting site, make sure you are logged in with netlify login`) } - console.log(`Initializing a new database for site: ${command.siteId} on account ${site.account_id} - Please wait...`) + const account = await getAccount(command, { accountId: siteInfo.account_id }) + + log(`Initializing a new database...`) const netlifyToken = command.netlify.api.accessToken.replace('Bearer ', '') const extension = await getExtension({ - accountId: site.account_id, + accountId: siteInfo.account_id, token: netlifyToken, - slug: NETLIFY_DATABASE_EXTENSION_SLUG, + slug: NEON_DATABASE_EXTENSION_SLUG, }) - if (!extension?.hostSiteUrl) { throw new Error(`Failed to get extension host site url when installing extension`) } - if (!extension.installedOnTeam) { - const answers = await inquirer.prompt([ + const installNeonExtension = async () => { + if (!siteInfo.account_id || !account.name || !extension.name || !extension.hostSiteUrl) { + 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) { + type Answers = { + installExtension: boolean + } + const answers = await inquirer.prompt([ { type: 'confirm', name: 'installExtension', - message: `Netlify Database extension is not installed on team ${site.account_id}, would you like to install it now?`, + message: `The required extension "${extension.name}" is not installed on team "${account.name}", would you like to install it now?`, }, ]) if (answers.installExtension) { - const installed = await installExtension({ - accountId: site.account_id, - token: netlifyToken, - slug: NETLIFY_DATABASE_EXTENSION_SLUG, - hostSiteUrl: extension.hostSiteUrl ?? '', - }) - if (!installed) { - throw new Error(`Failed to install extension on team ${site.account_id}: ${NETLIFY_DATABASE_EXTENSION_SLUG}`) - } - console.log(`Netlify Database extension installed on team ${site.account_id}`) + await installNeonExtension() } else { return } } + if (!extension.installedOnTeam && opts.yes) { + await installNeonExtension() + } try { const siteEnv = await command.netlify.api.getEnvVar({ - accountId: site.account_id, + accountId: siteInfo.account_id, siteId: command.siteId, key: 'NETLIFY_DATABASE_URL', }) if (siteEnv.key === 'NETLIFY_DATABASE_URL') { - console.error(`Database already initialized for site: ${command.siteId}`) + 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 extensionSiteUrl = process.env.UNSTABLE_NETLIFY_DATABASE_EXTENSION_HOST_SITE_URL ?? extension.hostSiteUrl + const hostSiteUrl = process.env.NEON_DATABASE_EXTENSION_HOST_SITE_URL ?? extension.hostSiteUrl + const initEndpoint = new URL('/cli-db-init', hostSiteUrl).toString() - const initEndpoint = new URL('/cli-db-init', extensionSiteUrl).toString() - console.log('initEndpoint', initEndpoint) const req = await fetch(initEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'nf-db-token': netlifyToken, 'nf-db-site-id': command.siteId, - 'nf-db-account-id': site.account_id, + 'nf-db-account-id': siteInfo.account_id, }, }) @@ -123,63 +158,85 @@ const init = async (_options: OptionValues, command: BaseCommand) => { throw new Error(`Failed to initialize DB: ${await req.text()}`) } - const res = await req.json() - console.log(res) + const res = (await req.json()) as { + code?: string + message?: string + } + if (res.code === 'DATABASE_INITIALIZED') { + if (res.message) { + log(res.message) + } + + log( + prettyjson.render({ + 'Current team': account.name, + 'Current site': siteInfo.name, + [`${extension.name} extension`]: 'installed', + Database: 'connected', + 'Site environment variable': 'NETLIFY_DATABASE_URL', + }), + ) + } return } const status = 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 + throw new Error(`The project must be linked with netlify link before initializing a database.`) } - // check if this site has a db initialized - const site = await command.netlify.api.getSite({ siteId: command.siteId }) - if (!site.account_id) { + 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 extensionInstallation = await getExtensionInstallations({ - accountId: site.account_id, - siteId: command.siteId, - token: netlifyToken, - }) - if (!extensionInstallation) { - console.log(`Netlify Database extension not installed on team ${site.account_id}`) - return + 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 siteConfig = await getSiteConfiguration({ - siteId: command.siteId, - accountId: site.account_id, - slug: NETLIFY_DATABASE_EXTENSION_SLUG, + const extension = await getExtension({ + accountId: account.id, token: netlifyToken, + slug: NEON_DATABASE_EXTENSION_SLUG, }) - - if (!siteConfig) { - throw new Error(`Failed to get site configuration for site ${command.siteId}`) - } + let siteConfig try { - const siteEnv = await command.netlify.api.getEnvVar({ - accountId: site.account_id, + siteConfig = await getSiteConfiguration({ siteId: command.siteId, - key: 'NETLIFY_DATABASE_URL', + accountId: siteInfo.account_id, + slug: NEON_DATABASE_EXTENSION_SLUG, + token: netlifyToken, }) - - if (siteEnv.key === 'NETLIFY_DATABASE_URL') { - console.log(`Database initialized for site: ${command.siteId}`) - return - } } catch { - throw new Error( - `Database not initialized for site: ${command.siteId}. -Run 'netlify db init' to initialize a database`, - ) + // 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'), + }), + ) } export const createDatabaseCommand = (program: BaseCommand) => { @@ -190,6 +247,8 @@ export const createDatabaseCommand = (program: BaseCommand) => { .description('Initialize a new database') .option('--drizzle', 'Sets up drizzle-kit and drizzle-orm in your project') .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('status').description('Check the status of the database').action(status) diff --git a/src/commands/database/drizzle.ts b/src/commands/database/drizzle.ts index 920f7954b87..078aff1b613 100644 --- a/src/commands/database/drizzle.ts +++ b/src/commands/database/drizzle.ts @@ -9,36 +9,52 @@ 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 + }>() const drizzleConfigFilePath = path.resolve(command.project.root, 'drizzle.config.ts') - await carefullyWriteFile(drizzleConfigFilePath, drizzleConfig) - - await fs.mkdir(path.resolve(command.project.root, 'db'), { recursive: true }) const schemaFilePath = path.resolve(command.project.root, 'db/schema.ts') - await carefullyWriteFile(schemaFilePath, exampleDrizzleSchema) - const dbIndexFilePath = path.resolve(command.project.root, 'db/index.ts') - await carefullyWriteFile(dbIndexFilePath, exampleDbIndex) + if (opts.overwrite) { + 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 { + 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') // 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)) + } - const answers = await inquirer.prompt([ - { - type: 'confirm', - name: 'updatePackageJson', - message: `Add drizzle db commands to package.json?`, - }, - ]) - if (answers.updatePackageJson) { - // 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) { + type Answers = { + updatePackageJson: boolean + } + 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)) } - await fs.writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2)) } // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access @@ -47,9 +63,6 @@ export const initDrizzle = async (command: BaseCommand) => { stdio: 'inherit', shell: true, }) - } else { - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions, @typescript-eslint/no-unsafe-member-access - console.log(`drizzle-kit already installed... Using version ${packageJson?.devDependencies?.['drizzle-kit']}`) } // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access if (!Object.keys(packageJson?.dependencies ?? {}).includes('drizzle-orm')) { @@ -57,9 +70,6 @@ export const initDrizzle = async (command: BaseCommand) => { stdio: 'inherit', shell: true, }) - } else { - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions, @typescript-eslint/no-unsafe-member-access - console.log(`drizzle-orm already installed... Using version ${packageJson?.dependencies?.['drizzle-orm']}`) } } diff --git a/src/commands/database/utils.ts b/src/commands/database/utils.ts index 95e35e59cb9..8d8e54bc307 100644 --- a/src/commands/database/utils.ts +++ b/src/commands/database/utils.ts @@ -1,52 +1,24 @@ import fsPromises from 'fs/promises' import fs from 'fs' import inquirer from 'inquirer' -import path from 'path' -const JIGSAW_URL = 'https://jigsaw.services-prod.nsvcs.net' - -export const getExtensionInstallations = async ({ - siteId, - accountId, - token, -}: { - siteId: string - accountId: string - token: string -}) => { - const installationsResponse = await fetch( - `${JIGSAW_URL}/team/${encodeURIComponent(accountId)}/integrations/installations/${encodeURIComponent(siteId)}`, - { - headers: { - 'netlify-token': token, - }, - }, - ) - - if (!installationsResponse.ok) { - return new Response('Failed to fetch installed extensions for site', { - status: 500, - }) - } - - const installations = await installationsResponse.json() - // console.log('installations', installations - return installations -} +import { JIGSAW_URL, NETLIFY_WEB_UI } from './constants.js' +import BaseCommand from '../base-command.js' export const getExtension = async ({ accountId, token, slug }: { accountId: string; token: string; slug: string }) => { - const fetchExtensionUrl = new URL('/.netlify/functions/fetch-extension', 'https://app.netlify.com/') - fetchExtensionUrl.searchParams.append('teamId', accountId) - fetchExtensionUrl.searchParams.append('slug', slug) + 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(fetchExtensionUrl.toString(), { + const extensionReq = await fetch(url.toString(), { headers: { Cookie: `_nf-auth=${token}`, }, }) const extension = (await extensionReq.json()) as | { - hostSiteUrl?: string + name: string + hostSiteUrl: string installedOnTeam: boolean } | undefined @@ -65,7 +37,8 @@ export const installExtension = async ({ slug: string hostSiteUrl: string }) => { - const installExtensionResponse = await fetch(`https://app.netlify.com/.netlify/functions/install-extension`, { + const url = new URL('/.netlify/functions/install-extension', NETLIFY_WEB_UI) + const installExtensionResponse = await fetch(url.toString(), { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -83,28 +56,9 @@ export const installExtension = async ({ } const installExtensionData = await installExtensionResponse.json() - console.log('installExtensionData', installExtensionData) - return installExtensionData } -export const carefullyWriteFile = async (filePath: string, data: string) => { - if (fs.existsSync(filePath)) { - const answers = await inquirer.prompt([ - { - type: 'confirm', - name: 'overwrite', - message: `Overwrite existing ${path.basename(filePath)}?`, - }, - ]) - if (answers.overwrite) { - await fsPromises.writeFile(filePath, data) - } - } else { - await fsPromises.writeFile(filePath, data) - } -} - export const getSiteConfiguration = async ({ siteId, accountId, @@ -116,15 +70,12 @@ export const getSiteConfiguration = async ({ token: string slug: string }) => { - const siteConfigurationResponse = await fetch( - `${JIGSAW_URL}/team/${accountId}/integrations/${slug}/configuration/site/${siteId}`, - { - headers: { - 'netlify-token': token, - }, + 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?`) } @@ -132,3 +83,49 @@ export const getSiteConfiguration = async ({ 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' + > +} From 4bce68a676acc389842c77ed496ed67583238d9e Mon Sep 17 00:00:00 2001 From: Caleb Barnes Date: Tue, 25 Mar 2025 11:51:14 -0700 Subject: [PATCH 08/10] remove commented unused call to get site --- src/commands/database/database.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/commands/database/database.ts b/src/commands/database/database.ts index bc023c9a3c3..08732da88e2 100644 --- a/src/commands/database/database.ts +++ b/src/commands/database/database.ts @@ -58,18 +58,6 @@ const init = async (_options: OptionValues, command: BaseCommand) => { throw new Error(`Please login with netlify login before running this command`) } - // let site: Awaited> - // try { - // site = await command.netlify.api.getSite({ - // siteId: command.siteId, - // // @ts-expect-error -- feature_flags is not in the types - // feature_flags: 'cli', - // }) - // } catch (e) { - // throw new Error(`Error getting site, make sure you are logged in with netlify login`, { - // cause: e, - // }) - // } if (!siteInfo.account_id || !siteInfo.name) { throw new Error(`Error getting site, make sure you are logged in with netlify login`) } From bd11f721d5d88603155d0c29c9bc0ba04b7b2df9 Mon Sep 17 00:00:00 2001 From: Caleb Barnes Date: Tue, 25 Mar 2025 11:53:37 -0700 Subject: [PATCH 09/10] remove unnecessary check --- src/commands/database/database.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/database/database.ts b/src/commands/database/database.ts index 08732da88e2..d3594700537 100644 --- a/src/commands/database/database.ts +++ b/src/commands/database/database.ts @@ -77,7 +77,7 @@ const init = async (_options: OptionValues, command: BaseCommand) => { } const installNeonExtension = async () => { - if (!siteInfo.account_id || !account.name || !extension.name || !extension.hostSiteUrl) { + if (!siteInfo.account_id || !account.name) { throw new Error(`Failed to install extension "${extension.name}"`) } const installed = await installExtension({ From a70d91581297d803dd1c90cf47b3ea50301b6d55 Mon Sep 17 00:00:00 2001 From: Karin <=> Date: Mon, 14 Apr 2025 17:29:00 +0200 Subject: [PATCH 10/10] feat: add local dev branch option --- src/commands/database/database.ts | 238 +++------------------------- src/commands/database/dev-branch.ts | 206 ++++++++++++++++++++++++ src/commands/database/drizzle.ts | 72 ++++++++- src/commands/database/init.ts | 180 +++++++++++++++++++++ src/commands/database/status.ts | 66 ++++++++ src/commands/database/utils.ts | 9 +- 6 files changed, 540 insertions(+), 231 deletions(-) create mode 100644 src/commands/database/dev-branch.ts create mode 100644 src/commands/database/init.ts create mode 100644 src/commands/database/status.ts diff --git a/src/commands/database/database.ts b/src/commands/database/database.ts index d3594700537..f10f9cd21cf 100644 --- a/src/commands/database/database.ts +++ b/src/commands/database/database.ts @@ -1,13 +1,16 @@ -import { OptionValues } from 'commander' -import inquirer from 'inquirer' import BaseCommand from '../base-command.js' -import { getAccount, getExtension, getSiteConfiguration, installExtension } from './utils.js' -import { initDrizzle } from './drizzle.js' -import { NEON_DATABASE_EXTENSION_SLUG } from './constants.js' -import prettyjson from 'prettyjson' -import { chalk, log } from '../../utils/command-helpers.js' +import { dev } from './dev-branch.js' +import { status } from './status.js' +import { init } from './init.js' -type SiteInfo = { +export type Extension = { + name: string + slug: string + hostSiteUrl: string + installedOnTeam: boolean +} + +export type SiteInfo = { id: string name: string account_id: string @@ -16,229 +19,24 @@ type SiteInfo = { ssl_url: string } -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() - - /** - * 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) { - type Answers = { - drizzle: boolean - } - const answers = await inquirer.prompt([ - { - type: 'confirm', - name: 'drizzle', - message: 'Use Drizzle?', - }, - ]) - command.setOptionValue('drizzle', answers.drizzle) - } - - const opts = command.opts<{ - drizzle?: boolean | undefined - /** - * Skip prompts and use default values (answer yes to all prompts) - */ - yes?: true | undefined - }>() - - if (opts.drizzle || (opts.yes && opts.drizzle !== false)) { - await initDrizzle(command) - } - - if (!command.netlify.api.accessToken) { - throw new Error(`Please login with netlify login before running this command`) - } - - if (!siteInfo.account_id || !siteInfo.name) { - throw new Error(`Error getting site, make sure you are logged in with netlify login`) - } - - const account = await getAccount(command, { accountId: siteInfo.account_id }) - - log(`Initializing a new database...`) - - 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 (!siteInfo.account_id || !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) { - type Answers = { - installExtension: boolean - } - 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() - } - - 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 req = await fetch(initEndpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'nf-db-token': netlifyToken, - 'nf-db-site-id': command.siteId, - 'nf-db-account-id': siteInfo.account_id, - }, - }) - - 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') { - if (res.message) { - log(res.message) - } - - log( - prettyjson.render({ - 'Current team': account.name, - 'Current site': siteInfo.name, - [`${extension.name} extension`]: 'installed', - Database: 'connected', - 'Site environment variable': 'NETLIFY_DATABASE_URL', - }), - ) - } - return -} - -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'), - }), - ) -} - 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('--drizzle', 'Sets up drizzle-kit and drizzle-orm in your project') + .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 index 078aff1b613..5c4751948cf 100644 --- a/src/commands/database/drizzle.ts +++ b/src/commands/database/drizzle.ts @@ -11,16 +11,29 @@ export const initDrizzle = async (command: BaseCommand) => { } const opts = command.opts<{ overwrite?: true | undefined + devBranchUri?: string | undefined }>() - const drizzleConfigFilePath = path.resolve(command.project.root, 'drizzle.config.ts') + 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) @@ -29,6 +42,20 @@ export const initDrizzle = async (command: BaseCommand) => { 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 @@ -41,10 +68,23 @@ export const initDrizzle = async (command: BaseCommand) => { 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) { - type Answers = { - updatePackageJson: boolean - } const answers = await inquirer.prompt([ { type: 'confirm', @@ -84,6 +124,17 @@ export default defineConfig({ 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', { @@ -122,3 +173,16 @@ const spawnAsync = (command: string, args: string[], options: Parameters { + 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/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 index 8d8e54bc307..a66e24b5591 100644 --- a/src/commands/database/utils.ts +++ b/src/commands/database/utils.ts @@ -4,6 +4,7 @@ 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) @@ -15,13 +16,7 @@ export const getExtension = async ({ accountId, token, slug }: { accountId: stri Cookie: `_nf-auth=${token}`, }, }) - const extension = (await extensionReq.json()) as - | { - name: string - hostSiteUrl: string - installedOnTeam: boolean - } - | undefined + const extension = (await extensionReq.json()) as Extension | undefined return extension }