From a33288e1b0dc8a654888972f2212f37337afbb97 Mon Sep 17 00:00:00 2001 From: Caleb Barnes Date: Wed, 19 Mar 2025 21:16:27 -0700 Subject: [PATCH 01/22] 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 95a6be1d8b2..54665e83a21 100644 --- a/src/commands/main.ts +++ b/src/commands/main.ts @@ -48,6 +48,7 @@ import { AddressInUseError } from './types.js' import { createUnlinkCommand } from './unlink/index.js' import { createWatchCommand } from './watch/index.js' import terminalLink from 'terminal-link' +import { createDatabaseCommand } from './database/database.js' const SUGGESTION_TIMEOUT = 1e4 @@ -236,6 +237,7 @@ export const createMainCommand = (): BaseCommand => { createUnlinkCommand(program) createWatchCommand(program) createLogsCommand(program) + createDatabaseCommand(program) program.setAnalyticsPayload({ didEnableCompileCache }) From fffd67321aab486674ff1e86a2b94088312b98bd Mon Sep 17 00:00:00 2001 From: Caleb Barnes Date: Wed, 19 Mar 2025 21:21:21 -0700 Subject: [PATCH 02/22] 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 4f4472894343fe12ec3931c57106479b4e45ddcc Mon Sep 17 00:00:00 2001 From: Caleb Barnes Date: Mon, 24 Mar 2025 09:34:02 -0700 Subject: [PATCH 03/22] 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 54665e83a21..f485e79e7e2 100644 --- a/src/commands/main.ts +++ b/src/commands/main.ts @@ -48,7 +48,7 @@ import { AddressInUseError } from './types.js' import { createUnlinkCommand } from './unlink/index.js' import { createWatchCommand } from './watch/index.js' import terminalLink from 'terminal-link' -import { createDatabaseCommand } from './database/database.js' +import { createDatabaseCommand } from './database/index.js' const SUGGESTION_TIMEOUT = 1e4 From 759fccb80a459bcdae0a9fff62c446854bc72ae3 Mon Sep 17 00:00:00 2001 From: Caleb Barnes Date: Mon, 24 Mar 2025 11:08:35 -0700 Subject: [PATCH 04/22] 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 dd1699d25364de849f2d4ab8ee8e8be918c672be Mon Sep 17 00:00:00 2001 From: Caleb Barnes Date: Tue, 25 Mar 2025 09:12:59 -0700 Subject: [PATCH 05/22] 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 107faf7d4918bdd2b285830416a8c8298dd513b3 Mon Sep 17 00:00:00 2001 From: Caleb Barnes Date: Tue, 25 Mar 2025 09:16:59 -0700 Subject: [PATCH 06/22] 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 2fd7ca08ead65c5a635ffbde4980c05c3b6dac67 Mon Sep 17 00:00:00 2001 From: Caleb Barnes Date: Tue, 25 Mar 2025 11:49:43 -0700 Subject: [PATCH 07/22] 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 b9e566dc53f7790642e939cbcae19ed2444bc7d1 Mon Sep 17 00:00:00 2001 From: Caleb Barnes Date: Tue, 25 Mar 2025 11:51:14 -0700 Subject: [PATCH 08/22] 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 5b08ee3b630006754d21d096cb07ad1c2c2606a8 Mon Sep 17 00:00:00 2001 From: Caleb Barnes Date: Tue, 25 Mar 2025 11:53:37 -0700 Subject: [PATCH 09/22] 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 115e19f5ebb39786a160266c0251d4268f86fee7 Mon Sep 17 00:00:00 2001 From: Karin <=> Date: Thu, 1 May 2025 17:21:22 -0600 Subject: [PATCH 10/22] fix: change slug into the neon slug --- src/commands/database/constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/database/constants.ts b/src/commands/database/constants.ts index f5bcd52ab7a..76797b325d1 100644 --- a/src/commands/database/constants.ts +++ b/src/commands/database/constants.ts @@ -1,3 +1,3 @@ -export const NEON_DATABASE_EXTENSION_SLUG = process.env.NEON_DATABASE_EXTENSION_SLUG ?? '7jjmnqyo-netlify-neon' +export const NEON_DATABASE_EXTENSION_SLUG = process.env.NEON_DATABASE_EXTENSION_SLUG ?? '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' From c4155cd25e515d161118597f96006a17640441a2 Mon Sep 17 00:00:00 2001 From: Caleb Barnes Date: Fri, 2 May 2025 12:59:01 -0700 Subject: [PATCH 11/22] Update db init command (#7257) * feat: add local dev branch option * add UNPOOLED env to db status command * add nf-db-user-id to req headers and update endpoint url * fix name for NETLIFY_DATABASE_URL_UNPOOLED * update NEON_DATABASE_EXTENSION_SLUG to 'neon' and remove NETLIFY_WEB_UI constant * remove dev command and dev-branch.ts * init: remove/replace headers for Nf-UIExt headers / remove dev branch questions * update getExtension, installExtension to call jigsaw directly instead of calling react ui endpoints * token -> netlifyToken * drizzle - fixes/cleanup & remove dev branch config * remove localDevBranch from Answers type --------- Co-authored-by: Karin <=> --- src/commands/database/constants.ts | 1 - src/commands/database/database.ts | 232 ++--------------------------- src/commands/database/drizzle.ts | 29 ++-- src/commands/database/init.ts | 160 ++++++++++++++++++++ src/commands/database/status.ts | 79 ++++++++++ src/commands/database/utils.ts | 160 +++++++++++++++----- 6 files changed, 390 insertions(+), 271 deletions(-) create mode 100644 src/commands/database/init.ts create mode 100644 src/commands/database/status.ts diff --git a/src/commands/database/constants.ts b/src/commands/database/constants.ts index 76797b325d1..8cb73b60c3d 100644 --- a/src/commands/database/constants.ts +++ b/src/commands/database/constants.ts @@ -1,3 +1,2 @@ export const NEON_DATABASE_EXTENSION_SLUG = process.env.NEON_DATABASE_EXTENSION_SLUG ?? '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 d3594700537..13a69eced27 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 { status } from './status.js' +import { init } from './init.js' -type SiteInfo = { +export type Extension = { + id: string + name: string + slug: string + hostSiteUrl: string + installedOnTeam: boolean +} + +export type SiteInfo = { id: string name: string account_id: string @@ -16,224 +19,13 @@ 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(`--drizzle`, 'Initialize basic drizzle config and schema boilerplate') .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') diff --git a/src/commands/database/drizzle.ts b/src/commands/database/drizzle.ts index 078aff1b613..8f934394a95 100644 --- a/src/commands/database/drizzle.ts +++ b/src/commands/database/drizzle.ts @@ -12,19 +12,20 @@ export const initDrizzle = async (command: BaseCommand) => { const opts = command.opts<{ overwrite?: true | undefined }>() + const drizzleConfigFilePath = path.resolve(command.project.root, 'drizzle.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) { 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) + await fs.writeFile(schemaFilePath, drizzleSchema) + await fs.writeFile(dbIndexFilePath, dbIndex) } 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) + await carefullyWriteFile(schemaFilePath, drizzleSchema, command.project.root) + await carefullyWriteFile(dbIndexFilePath, dbIndex, command.project.root) } const packageJsonPath = path.resolve(command.project.root, 'package.json') @@ -41,10 +42,11 @@ export const initDrizzle = async (command: BaseCommand) => { await fs.writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2)) } + type Answers = { + updatePackageJson: boolean + } + if (!opts.overwrite) { - type Answers = { - updatePackageJson: boolean - } const answers = await inquirer.prompt([ { type: 'confirm', @@ -84,7 +86,7 @@ export default defineConfig({ out: './migrations' });` -const exampleDrizzleSchema = `import { integer, pgTable, varchar, text } from 'drizzle-orm/pg-core'; +const drizzleSchema = `import { integer, pgTable, varchar, text } from 'drizzle-orm/pg-core'; export const posts = pgTable('posts', { id: integer().primaryKey().generatedAlwaysAsIdentity(), @@ -92,10 +94,10 @@ export const posts = pgTable('posts', { content: text().notNull().default('') });` -const exampleDbIndex = `import { neon } from '@netlify/neon'; +const dbIndex = `import { neon } from '@netlify/neon'; import { drizzle } from 'drizzle-orm/neon-http'; -import * as schema from 'db/schema'; +import * as schema from './schema'; export const db = drizzle({ schema, @@ -103,10 +105,9 @@ export const db = drizzle({ });` 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', + 'db:generate': 'drizzle-kit generate', + 'db:migrate': 'netlify dev:exec drizzle-kit migrate', + 'db:studio': 'netlify dev:exec drizzle-kit studio', } const spawnAsync = (command: string, args: string[], options: Parameters[2]): Promise => { diff --git a/src/commands/database/init.ts b/src/commands/database/init.ts new file mode 100644 index 00000000000..d3d298ebb53 --- /dev/null +++ b/src/commands/database/init.ts @@ -0,0 +1,160 @@ +import { OptionValues } from 'commander' +import inquirer from 'inquirer' +import BaseCommand from '../base-command.js' +import { getAccount, getExtension, getJigsawToken, 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' + +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 + } + + const opts = command.opts<{ + drizzle?: boolean | undefined + /** + * Skip prompts and use default values (answer yes to all prompts) + */ + yes?: true | 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, + netlifyToken: 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, + netlifyToken: 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) + } + + if (opts.drizzle || (opts.yes && opts.drizzle !== false)) { + log(`Initializing drizzle...`) + await initDrizzle(command) + } + + log(`Initializing a new database...`) + const hostSiteUrl = process.env.EXTENSION_HOST_SITE_URL ?? extension.hostSiteUrl + const initEndpoint = new URL('/api/cli-db-init', hostSiteUrl).toString() + const currentUser = await command.netlify.api.getCurrentUser() + + const { data: jigsawToken, error } = await getJigsawToken({ + netlifyToken: netlifyToken, + accountId: siteInfo.account_id, + integrationSlug: extension.slug, + }) + if (error || !jigsawToken) { + throw new Error(`Failed to get jigsaw token: ${error?.message ?? 'Unknown error'}`) + } + + const headers = { + 'Content-Type': 'application/json', + 'Nf-UIExt-Netlify-Token': jigsawToken, + 'Nf-UIExt-Netlify-Token-Issuer': 'jigsaw', + 'Nf-UIExt-Extension-Id': extension.id, + 'Nf-UIExt-Extension-Slug': extension.slug, + 'Nf-UIExt-Site-Id': command.siteId ?? '', + 'Nf-UIExt-Team-Id': siteInfo.account_id, + 'Nf-UIExt-User-Id': currentUser.id ?? '', + } + const req = await fetch(initEndpoint, { + method: 'POST', + headers, + }) + + if (!req.ok) { + const error = (await req.json()) as { + code?: string + message?: string + } + throw new Error(`Failed to initialize DB: ${error.message ?? 'Unknown error occurred'}`) + } + + 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'}`) + } + + log( + prettyjson.render({ + 'Current team': account.name, + 'Current site': siteInfo.name, + [`${extension.name} extension`]: 'installed on team', + ['Database status']: 'connected to site', + ['Environment variables']: '', + [' NETLIFY_DATABASE_URL']: 'saved', + [' NETLIFY_DATABASE_URL_UNPOOLED']: 'saved', + }), + ) + return +} diff --git a/src/commands/database/status.ts b/src/commands/database/status.ts new file mode 100644 index 00000000000..723cff0b66e --- /dev/null +++ b/src/commands/database/status.ts @@ -0,0 +1,79 @@ +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 databaseUrlEnv: Awaited> | undefined + let unpooledDatabaseUrlEnv: Awaited> | undefined + + try { + databaseUrlEnv = 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 + } + try { + unpooledDatabaseUrlEnv = await command.netlify.api.getEnvVar({ + accountId: siteInfo.account_id, + siteId: command.siteId, + key: 'NETLIFY_DATABASE_URL_UNPOOLED', + }) + } catch { + // no-op, env var does not exist, so we just continue + } + + const extension = await getExtension({ + accountId: account.id, + netlifyToken: netlifyToken, + slug: NEON_DATABASE_EXTENSION_SLUG, + }) + let siteConfig + try { + siteConfig = await getSiteConfiguration({ + siteId: command.siteId, + accountId: siteInfo.account_id, + slug: NEON_DATABASE_EXTENSION_SLUG, + netlifyToken: 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 on team' + : chalk.red('not installed on team'), + // @ts-expect-error -- siteConfig is not typed + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + ['Database status']: siteConfig?.config?.connectedDatabase ? 'connected to site' : chalk.red('not connected'), + ['Environment variables']: '', + [' NETLIFY_DATABASE_URL']: databaseUrlEnv?.key === 'NETLIFY_DATABASE_URL' ? 'saved' : chalk.red('not set'), + [' NETLIFY_DATABASE_URL_UNPOOLED']: + unpooledDatabaseUrlEnv?.key === 'NETLIFY_DATABASE_URL_UNPOOLED' ? 'saved' : chalk.red('not set'), + }), + ) +} diff --git a/src/commands/database/utils.ts b/src/commands/database/utils.ts index 8d8e54bc307..7c25e36c587 100644 --- a/src/commands/database/utils.ts +++ b/src/commands/database/utils.ts @@ -2,84 +2,96 @@ import fsPromises from 'fs/promises' import fs from 'fs' import inquirer from 'inquirer' -import { JIGSAW_URL, NETLIFY_WEB_UI } from './constants.js' +import { JIGSAW_URL } from './constants.js' import BaseCommand from '../base-command.js' +import { Extension } from './database.js' -export const getExtension = async ({ accountId, token, slug }: { accountId: string; token: string; slug: string }) => { - const url = new URL('/.netlify/functions/fetch-extension', NETLIFY_WEB_UI) - url.searchParams.append('teamId', accountId) - url.searchParams.append('slug', slug) - - const extensionReq = await fetch(url.toString(), { - headers: { - Cookie: `_nf-auth=${token}`, +export const getExtension = async ({ + accountId, + netlifyToken, + slug, +}: { + accountId: string + netlifyToken: string + slug: string +}) => { + const extensionResponse = await fetch( + `${JIGSAW_URL}/${encodeURIComponent(accountId)}/integrations/${encodeURIComponent(slug)}`, + { + headers: { + 'netlify-token': netlifyToken, + 'Api-Version': '2', + }, }, - }) - const extension = (await extensionReq.json()) as - | { - name: string - hostSiteUrl: string - installedOnTeam: boolean - } - | undefined + ) + if (!extensionResponse.ok) { + throw new Error(`Failed to fetch extension: ${slug}`) + } + + const extension = (await extensionResponse.json()) as Extension | undefined return extension } export const installExtension = async ({ - token, + netlifyToken, accountId, slug, hostSiteUrl, }: { - token: string + netlifyToken: string accountId: string slug: string hostSiteUrl: string }) => { - const url = new URL('/.netlify/functions/install-extension', NETLIFY_WEB_UI) - const installExtensionResponse = await fetch(url.toString(), { + const { data: jigsawToken, error } = await getJigsawToken({ + netlifyToken: netlifyToken, + accountId, + integrationSlug: slug, + isEnable: true, + }) + if (error || !jigsawToken) { + throw new Error(`Failed to get Jigsaw token: ${error?.message ?? 'Unknown error'}`) + } + + const extensionOnInstallUrl = new URL('/.netlify/functions/handler/on-install', hostSiteUrl) + const installedResponse = await fetch(extensionOnInstallUrl, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - Cookie: `_nf-auth=${token}`, - }, body: JSON.stringify({ teamId: accountId, - slug, - hostSiteUrl, }), + headers: { + 'netlify-token': jigsawToken, + }, }) - if (!installExtensionResponse.ok) { - throw new Error(`Failed to install extension: ${slug}`) + if (!installedResponse.ok && installedResponse.status !== 409) { + const text = await installedResponse.text() + throw new Error(`Failed to install extension '${slug}': ${text}`) } - - const installExtensionData = await installExtensionResponse.json() - return installExtensionData + return true } export const getSiteConfiguration = async ({ siteId, accountId, - token, + netlifyToken, slug, }: { siteId: string accountId: string - token: string + netlifyToken: string slug: string }) => { const url = new URL(`/team/${accountId}/integrations/${slug}/configuration/site/${siteId}`, JIGSAW_URL) const siteConfigurationResponse = await fetch(url.toString(), { headers: { - 'netlify-token': token, + 'netlify-token': netlifyToken, }, }) 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 } @@ -129,3 +141,79 @@ export const getAccount = async ( 'id' | 'name' > } + +type JigsawTokenResult = + | { + data: string + error: null + } + | { + data: null + error: { code: number; message: string } + } + +export const getJigsawToken = async ({ + netlifyToken, + accountId, + integrationSlug, + isEnable, +}: { + netlifyToken: string + accountId: string + integrationSlug?: string + /** + * isEnable will make a token that can install the extension + */ + isEnable?: boolean +}): Promise => { + try { + const tokenResponse = await fetch(`${JIGSAW_URL}/generate-token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Cookie: `_nf-auth=${netlifyToken}`, + 'Api-Version': '2', + }, + body: JSON.stringify({ + ownerId: accountId, + integrationSlug, + isEnable, + }), + }) + + if (!tokenResponse.ok) { + return { + data: null, + error: { + code: 401, + message: `Unauthorized`, + }, + } + } + + const tokenData = (await tokenResponse.json()) as { token?: string } | undefined + + if (!tokenData?.token) { + return { + data: null, + error: { + code: 401, + message: `Unauthorized`, + }, + } + } + return { + data: tokenData.token, + error: null, + } + } catch (e) { + console.error('Failed to get Jigsaw token', e) + return { + data: null, + error: { + code: 401, + message: `Unauthorized`, + }, + } + } +} From c0459ac36cd473fb604a62e71b81458ea800efd6 Mon Sep 17 00:00:00 2001 From: Caleb Barnes Date: Fri, 2 May 2025 13:06:36 -0700 Subject: [PATCH 12/22] update db command help text --- src/commands/database/database.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/commands/database/database.ts b/src/commands/database/database.ts index 13a69eced27..38c8bca4289 100644 --- a/src/commands/database/database.ts +++ b/src/commands/database/database.ts @@ -20,7 +20,10 @@ export type SiteInfo = { } export const createDatabaseCommand = (program: BaseCommand) => { - const dbCommand = program.command('db').alias('database').description(`TODO: write description for database command`) + const dbCommand = program + .command('db') + .alias('database') + .description(`Provision a production ready Postgres database with a single command`) dbCommand .command('init') From f30f19506705843b5ceb9a98d5ec934552013975 Mon Sep 17 00:00:00 2001 From: Caleb Barnes Date: Fri, 2 May 2025 13:26:55 -0700 Subject: [PATCH 13/22] update command descriptions and examples --- src/commands/database/database.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/commands/database/database.ts b/src/commands/database/database.ts index 38c8bca4289..1c2bceaa6fc 100644 --- a/src/commands/database/database.ts +++ b/src/commands/database/database.ts @@ -24,17 +24,23 @@ export const createDatabaseCommand = (program: BaseCommand) => { .command('db') .alias('database') .description(`Provision a production ready Postgres database with a single command`) + .addExamples([ + 'netlify db status', + 'netlify db init', + 'netlify db init --drizzle', + 'netlify db init --drizzle --overwrite', + ]) dbCommand .command('init') - .description('Initialize a new database') + .description(`Initialize a new database for the current site`) .option(`--drizzle`, 'Initialize basic drizzle config and schema boilerplate') .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) + dbCommand.command('status').description(`Check the status of the database`).action(status) return dbCommand } From 836fd294cbf76ffd1bee0d14477de9ed4bed494a Mon Sep 17 00:00:00 2001 From: Caleb Barnes Date: Fri, 2 May 2025 13:37:38 -0700 Subject: [PATCH 14/22] update help command snapshot --- tests/integration/commands/help/__snapshots__/help.test.ts.snap | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/integration/commands/help/__snapshots__/help.test.ts.snap b/tests/integration/commands/help/__snapshots__/help.test.ts.snap index 892a40c3196..0eecf39b58f 100644 --- a/tests/integration/commands/help/__snapshots__/help.test.ts.snap +++ b/tests/integration/commands/help/__snapshots__/help.test.ts.snap @@ -16,6 +16,8 @@ COMMANDS $ clone Clone a remote repository and link it to an existing project on Netlify $ completion Generate shell completion script + $ db Provision a production ready Postgres database with a single + command $ deploy Create a new deploy from the contents of a folder $ dev Local dev server $ env Control environment variables for the current project From 254cc6db81687caa075467ea19dc5aaa70f0a26f Mon Sep 17 00:00:00 2001 From: Caleb Barnes Date: Fri, 2 May 2025 16:06:32 -0700 Subject: [PATCH 15/22] support docs gen for sub commands without ":" --- site/scripts/docs.js | 6 +++++- site/scripts/util/generate-command-data.js | 9 +++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/site/scripts/docs.js b/site/scripts/docs.js index 489aa61d35f..208a709e264 100755 --- a/site/scripts/docs.js +++ b/site/scripts/docs.js @@ -100,7 +100,11 @@ const commandListSubCommandDisplay = function (commands) { let table = '| Subcommand | description |\n' table += '|:--------------------------- |:-----|\n' commands.forEach((cmd) => { - const [commandBase] = cmd.name.split(':') + let commandBase + commandBase = cmd.name.split(':')[0] + if (cmd.parent) { + commandBase = cmd.parent + } const baseUrl = `/commands/${commandBase}` const slug = cmd.name.replace(/:/g, '') table += `| [\`${cmd.name}\`](${baseUrl}#${slug}) | ${cmd.description.split('\n')[0]} |\n` diff --git a/site/scripts/util/generate-command-data.js b/site/scripts/util/generate-command-data.js index 9fa3599fdea..c46225bf0ce 100644 --- a/site/scripts/util/generate-command-data.js +++ b/site/scripts/util/generate-command-data.js @@ -35,12 +35,13 @@ const parseCommand = function (command) { }, {}) return { + parent: command.parent?.name() !== "netlify" ? command.parent?.name() : undefined, name: command.name(), description: command.description(), - commands: commands - - .filter((cmd) => cmd.name().startsWith(`${command.name()}:`) && !cmd._hidden) - .map((cmd) => parseCommand(cmd)), + commands: [ + ...command.commands.filter(cmd => !cmd._hidden).map(cmd => parseCommand(cmd)), + ...commands.filter((cmd) => cmd.name().startsWith(`${command.name()}:`) && !cmd._hidden).map(cmd => parseCommand(cmd)) + ], examples: command.examples.length !== 0 && command.examples, args: args.length !== 0 && args, flags: Object.keys(flags).length !== 0 && flags, From 9085a8fb9fc5a10065cfae243c5fd69316b56e89 Mon Sep 17 00:00:00 2001 From: Caleb Barnes Date: Fri, 2 May 2025 16:08:38 -0700 Subject: [PATCH 16/22] docs gen for db commands --- docs/commands/db.md | 86 +++++++++++++++++++++++++++++++++++++++++++++ docs/index.md | 10 ++++++ 2 files changed, 96 insertions(+) create mode 100644 docs/commands/db.md diff --git a/docs/commands/db.md b/docs/commands/db.md new file mode 100644 index 00000000000..db33bb64cf0 --- /dev/null +++ b/docs/commands/db.md @@ -0,0 +1,86 @@ +--- +title: Netlify CLI db command +description: Provision a production ready Postgres database with a single command +sidebar: + label: db +--- + +# `db` + + + +Provision a production ready Postgres database with a single command + +**Usage** + +```bash +netlify db +``` + +**Flags** + +- `filter` (*string*) - For monorepos, specify the name of the application to run the command in +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in + +| Subcommand | description | +|:--------------------------- |:-----| +| [`init`](/commands/db#init) | Initialize a new database for the current site | +| [`status`](/commands/db#status) | Check the status of the database | + + +**Examples** + +```bash +netlify db status +netlify db init +netlify db init --help +``` + +--- +## `init` + +Initialize a new database for the current site + +**Usage** + +```bash +netlify init +``` + +**Flags** + +- `drizzle` (*boolean*) - Initialize basic drizzle config and schema boilerplate +- `filter` (*string*) - For monorepos, specify the name of the application to run the command in +- `minimal` (*boolean*) - Minimal non-interactive setup. Does not initialize drizzle or any boilerplate. Ideal for CI or AI tools. +- `no-drizzle` (*boolean*) - Does not initialize drizzle and skips any related prompts +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in +- `overwrite` (*boolean*) - Overwrites existing files that would be created when setting up drizzle + +**Examples** + +```bash +netlify db init --minimal +netlify db init --drizzle --overwrite +``` + +--- +## `status` + +Check the status of the database + +**Usage** + +```bash +netlify status +``` + +**Flags** + +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in + +--- + + diff --git a/docs/index.md b/docs/index.md index 028e960e114..bd9691f8ce0 100644 --- a/docs/index.md +++ b/docs/index.md @@ -51,6 +51,16 @@ Generate shell completion script | [`completion:install`](/commands/completion#completioninstall) | Generates completion script for your preferred shell | +### [db](/commands/db) + +Provision a production ready Postgres database with a single command + +| Subcommand | description | +|:--------------------------- |:-----| +| [`init`](/commands/db#init) | Initialize a new database for the current site | +| [`status`](/commands/db#status) | Check the status of the database | + + ### [deploy](/commands/deploy) Create a new deploy from the contents of a folder From dbb78c74a0318011841cd9058b5e5f85b8a9bc4a Mon Sep 17 00:00:00 2001 From: Caleb Barnes Date: Fri, 2 May 2025 16:09:24 -0700 Subject: [PATCH 17/22] remove "yes" flag and add "minimal" flag --- src/commands/database/database.ts | 17 ++++++------- src/commands/database/init.ts | 41 ++++++++++--------------------- 2 files changed, 20 insertions(+), 38 deletions(-) diff --git a/src/commands/database/database.ts b/src/commands/database/database.ts index 1c2bceaa6fc..978e198be04 100644 --- a/src/commands/database/database.ts +++ b/src/commands/database/database.ts @@ -24,23 +24,20 @@ export const createDatabaseCommand = (program: BaseCommand) => { .command('db') .alias('database') .description(`Provision a production ready Postgres database with a single command`) - .addExamples([ - 'netlify db status', - 'netlify db init', - 'netlify db init --drizzle', - 'netlify db init --drizzle --overwrite', - ]) + .addExamples(['netlify db status', 'netlify db init', 'netlify db init --help']) dbCommand .command('init') .description(`Initialize a new database for the current site`) .option(`--drizzle`, 'Initialize basic drizzle config and schema boilerplate') - .option('--no-drizzle', 'Skips drizzle') - .option('-y, --yes', 'Skip prompts and use default values') + .option('--no-drizzle', 'Does not initialize drizzle and skips any related prompts') + .option( + '--minimal', + 'Minimal non-interactive setup. Does not initialize drizzle or any boilerplate. Ideal for CI or AI tools.', + ) .option('-o, --overwrite', 'Overwrites existing files that would be created when setting up drizzle') .action(init) + .addExamples([`netlify db init --minimal`, `netlify db init --drizzle --overwrite`]) dbCommand.command('status').description(`Check the status of the database`).action(status) - - return dbCommand } diff --git a/src/commands/database/init.ts b/src/commands/database/init.ts index d3d298ebb53..55c72f03d2b 100644 --- a/src/commands/database/init.ts +++ b/src/commands/database/init.ts @@ -17,23 +17,20 @@ export const init = async (_options: OptionValues, command: BaseCommand) => { const initialOpts = command.opts() - type Answers = { - drizzle: boolean - installExtension: boolean - } - const opts = command.opts<{ drizzle?: boolean | undefined - /** - * Skip prompts and use default values (answer yes to all prompts) - */ - yes?: true | undefined + overwrite?: boolean | undefined + minimal?: boolean | undefined }>() if (!command.netlify.api.accessToken || !siteInfo.account_id || !siteInfo.name) { throw new Error(`Please login with netlify login before running this command`) } + if (opts.minimal === true) { + command.setOptionValue('drizzle', false) + } + const account = await getAccount(command, { accountId: siteInfo.account_id }) const netlifyToken = command.netlify.api.accessToken.replace('Bearer ', '') @@ -63,28 +60,17 @@ export const init = async (_options: OptionValues, command: BaseCommand) => { 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) { + if (!extension.installedOnTeam) { 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([ + if (initialOpts.drizzle !== false && initialOpts.drizzle !== true) { + const answers = await inquirer.prompt<{ + drizzle: boolean + }>([ { type: 'confirm', name: 'drizzle', @@ -93,8 +79,7 @@ export const init = async (_options: OptionValues, command: BaseCommand) => { ]) command.setOptionValue('drizzle', answers.drizzle) } - - if (opts.drizzle || (opts.yes && opts.drizzle !== false)) { + if (opts.drizzle) { log(`Initializing drizzle...`) await initDrizzle(command) } From e151484c711d247b1e43d435ec9e7ebf73ce062d Mon Sep 17 00:00:00 2001 From: Caleb Barnes Date: Mon, 5 May 2025 15:32:25 -0700 Subject: [PATCH 18/22] improve db init ux - dont throw error on CONFLICT (db already connected) - if db already connected, we just say its connected and continue to log the status - improve status by fetching cli-db-status extension endpoint --- src/commands/database/init.ts | 41 ++++++++++++++++++++++++++--------- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/src/commands/database/init.ts b/src/commands/database/init.ts index 55c72f03d2b..665a5344786 100644 --- a/src/commands/database/init.ts +++ b/src/commands/database/init.ts @@ -118,16 +118,33 @@ export const init = async (_options: OptionValues, command: BaseCommand) => { code?: string message?: string } - throw new Error(`Failed to initialize DB: ${error.message ?? 'Unknown error occurred'}`) + if (error.code === 'CONFLICT') { + log(`Database already connected to this site. Skipping initialization.`) + } else { + throw new Error(`Failed to initialize DB: ${error.message ?? 'Unknown error occurred'}`) + } } - const res = (await req.json()) as { - code?: string - message?: string - } + let status - if (res.code !== 'DATABASE_INITIALIZED') { - throw new Error(`Failed to initialize DB: ${res.message ?? 'Unknown error'}`) + try { + const statusEndpoint = new URL('/api/cli-db-status', hostSiteUrl).toString() + const statusRes = await fetch(statusEndpoint, { + headers, + }) + if (!statusRes.ok) { + throw new Error(`Failed to get database status`, { cause: statusRes }) + } + status = (await statusRes.json()) as { + siteConfiguration?: { + connectedDatabase?: { + isConnected: boolean + } + } + existingManagedEnvs?: string[] + } + } catch (e) { + console.error('Failed to get database status', e) } log( @@ -135,10 +152,14 @@ export const init = async (_options: OptionValues, command: BaseCommand) => { 'Current team': account.name, 'Current site': siteInfo.name, [`${extension.name} extension`]: 'installed on team', - ['Database status']: 'connected to site', + ['Database status']: status?.siteConfiguration?.connectedDatabase?.isConnected + ? 'connected to site' + : 'not connected', ['Environment variables']: '', - [' NETLIFY_DATABASE_URL']: 'saved', - [' NETLIFY_DATABASE_URL_UNPOOLED']: 'saved', + [' NETLIFY_DATABASE_URL']: status?.existingManagedEnvs?.includes('NETLIFY_DATABASE_URL') ? 'saved' : 'not set', + [' NETLIFY_DATABASE_URL_UNPOOLED']: status?.existingManagedEnvs?.includes('NETLIFY_DATABASE_URL_UNPOOLED') + ? 'saved' + : 'not set', }), ) return From cadddbccb0438e863a3a48d4309157021d8fa4b5 Mon Sep 17 00:00:00 2001 From: Caleb Barnes Date: Wed, 14 May 2025 09:20:10 -0700 Subject: [PATCH 19/22] install @netlify/neon package if not found in package.json --- src/commands/database/constants.ts | 1 + src/commands/database/drizzle.ts | 32 +++++------------------ src/commands/database/init.ts | 16 ++++++++++-- src/commands/database/utils.ts | 42 ++++++++++++++++++++++++++++++ 4 files changed, 64 insertions(+), 27 deletions(-) diff --git a/src/commands/database/constants.ts b/src/commands/database/constants.ts index 8cb73b60c3d..36376fa7048 100644 --- a/src/commands/database/constants.ts +++ b/src/commands/database/constants.ts @@ -1,2 +1,3 @@ export const NEON_DATABASE_EXTENSION_SLUG = process.env.NEON_DATABASE_EXTENSION_SLUG ?? 'neon' export const JIGSAW_URL = process.env.JIGSAW_URL ?? 'https://api.netlifysdk.com' +export const NETLIFY_NEON_PACKAGE_NAME = '@netlify/neon' diff --git a/src/commands/database/drizzle.ts b/src/commands/database/drizzle.ts index 8f934394a95..e89281b2e1d 100644 --- a/src/commands/database/drizzle.ts +++ b/src/commands/database/drizzle.ts @@ -1,9 +1,9 @@ -import { spawn } from 'child_process' -import { carefullyWriteFile } from './utils.js' +import { carefullyWriteFile, getPackageJSON, spawnAsync } from './utils.js' import BaseCommand from '../base-command.js' import path from 'path' import fs from 'fs/promises' import inquirer from 'inquirer' +import { NETLIFY_NEON_PACKAGE_NAME } from './constants.js' export const initDrizzle = async (command: BaseCommand) => { if (!command.project.root) { @@ -29,12 +29,9 @@ export const initDrizzle = async (command: BaseCommand) => { } const packageJsonPath = path.resolve(command.project.root, 'package.json') + const packageJson = getPackageJSON(command.workingDir) - // 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, } @@ -59,15 +56,14 @@ export const initDrizzle = async (command: BaseCommand) => { } } - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access - if (!Object.keys(packageJson?.devDependencies ?? {}).includes('drizzle-kit')) { + if (!Object.keys(packageJson.devDependencies ?? {}).includes('drizzle-kit')) { await spawnAsync(command.project.packageManager?.installCommand ?? 'npm install', ['drizzle-kit@latest', '-D'], { stdio: 'inherit', shell: true, }) } - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access - if (!Object.keys(packageJson?.dependencies ?? {}).includes('drizzle-orm')) { + + if (!Object.keys(packageJson.dependencies ?? {}).includes('drizzle-orm')) { await spawnAsync(command.project.packageManager?.installCommand ?? 'npm install', ['drizzle-orm@latest'], { stdio: 'inherit', shell: true, @@ -94,7 +90,7 @@ export const posts = pgTable('posts', { content: text().notNull().default('') });` -const dbIndex = `import { neon } from '@netlify/neon'; +const dbIndex = `import { neon } from '${NETLIFY_NEON_PACKAGE_NAME}'; import { drizzle } from 'drizzle-orm/neon-http'; import * as schema from './schema'; @@ -109,17 +105,3 @@ const packageJsonScripts = { 'db:migrate': 'netlify dev:exec drizzle-kit migrate', 'db:studio': 'netlify dev:exec 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/init.ts b/src/commands/database/init.ts index 665a5344786..e3d6ca472b6 100644 --- a/src/commands/database/init.ts +++ b/src/commands/database/init.ts @@ -1,14 +1,26 @@ import { OptionValues } from 'commander' import inquirer from 'inquirer' import BaseCommand from '../base-command.js' -import { getAccount, getExtension, getJigsawToken, installExtension } from './utils.js' +import { getAccount, getExtension, getJigsawToken, getPackageJSON, installExtension, spawnAsync } from './utils.js' import { initDrizzle } from './drizzle.js' -import { NEON_DATABASE_EXTENSION_SLUG } from './constants.js' +import { NEON_DATABASE_EXTENSION_SLUG, NETLIFY_NEON_PACKAGE_NAME } from './constants.js' import prettyjson from 'prettyjson' import { log } from '../../utils/command-helpers.js' import { SiteInfo } from './database.js' export const init = async (_options: OptionValues, command: BaseCommand) => { + try { + const packageJson = getPackageJSON(command.workingDir) + if (packageJson.dependencies && !Object.keys(packageJson.dependencies).includes(NETLIFY_NEON_PACKAGE_NAME)) { + await spawnAsync(command.project.packageManager?.installCommand ?? 'npm install', ['@netlify/neon@latest'], { + stdio: 'inherit', + shell: true, + }) + } + } catch (e) { + console.error(`Failed to install @netlify/neon in ${command.workingDir}:`, e) + } + const siteInfo = command.netlify.siteInfo as SiteInfo if (!command.siteId) { console.error(`The project must be linked with netlify link before initializing a database.`) diff --git a/src/commands/database/utils.ts b/src/commands/database/utils.ts index 7c25e36c587..2740c567d2b 100644 --- a/src/commands/database/utils.ts +++ b/src/commands/database/utils.ts @@ -1,3 +1,6 @@ +import { createRequire } from 'module' +import { join } from 'path' + import fsPromises from 'fs/promises' import fs from 'fs' import inquirer from 'inquirer' @@ -5,6 +8,45 @@ import inquirer from 'inquirer' import { JIGSAW_URL } from './constants.js' import BaseCommand from '../base-command.js' import { Extension } from './database.js' +import { spawn } from 'child_process' + +type PackageJSON = { + dependencies?: Record + devDependencies?: Record + scripts?: Record +} + +export function getPackageJSON(directory: string) { + const require = createRequire(join(directory, 'package.json')) + const packageJson = require('./package.json') as unknown + if (typeof packageJson !== 'object' || packageJson === null) { + throw new Error('Failed to load package.json') + } + if ('dependencies' in packageJson && typeof packageJson.dependencies !== 'object') { + throw new Error(`Expected object at package.json#dependencies, got ${typeof packageJson.dependencies}`) + } + if ('devDependencies' in packageJson && typeof packageJson.devDependencies !== 'object') { + throw new Error(`Expected object at package.json#devDependencies, got ${typeof packageJson.devDependencies}`) + } + if ('scripts' in packageJson && typeof packageJson.scripts !== 'object') { + throw new Error(`Expected object at package.json#scripts, got ${typeof packageJson.scripts}`) + } + return packageJson as PackageJSON +} + +export const spawnAsync = (command: string, args: string[], options: Parameters[2]): Promise => { + return new Promise((resolve, reject) => { + const child = spawn(command, args, options) + child.on('error', reject) + child.on('exit', (code) => { + if (code === 0) { + resolve(code) + } + const errorMessage = code ? `Process exited with code ${code.toString()}` : 'Process exited with no code' + reject(new Error(errorMessage)) + }) + }) +} export const getExtension = async ({ accountId, From 6ba266a2440c4d77a4fc19da6188d38f338ec059 Mon Sep 17 00:00:00 2001 From: Caleb Barnes Date: Tue, 20 May 2025 12:10:18 -0700 Subject: [PATCH 20/22] use same package json path --- src/commands/database/drizzle.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/database/drizzle.ts b/src/commands/database/drizzle.ts index e89281b2e1d..742378ad53b 100644 --- a/src/commands/database/drizzle.ts +++ b/src/commands/database/drizzle.ts @@ -28,7 +28,7 @@ export const initDrizzle = async (command: BaseCommand) => { await carefullyWriteFile(dbIndexFilePath, dbIndex, command.project.root) } - const packageJsonPath = path.resolve(command.project.root, 'package.json') + const packageJsonPath = path.resolve(command.workingDir, 'package.json') const packageJson = getPackageJSON(command.workingDir) packageJson.scripts = { From a2d94836efb51fb7afabf0830c2a9b9b7ab4552c Mon Sep 17 00:00:00 2001 From: Caleb Barnes Date: Tue, 20 May 2025 12:10:43 -0700 Subject: [PATCH 21/22] move neon package installation to end to avoid incorrect overwriting --- src/commands/database/init.ts | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/src/commands/database/init.ts b/src/commands/database/init.ts index e3d6ca472b6..b711ace1039 100644 --- a/src/commands/database/init.ts +++ b/src/commands/database/init.ts @@ -9,18 +9,6 @@ import { log } from '../../utils/command-helpers.js' import { SiteInfo } from './database.js' export const init = async (_options: OptionValues, command: BaseCommand) => { - try { - const packageJson = getPackageJSON(command.workingDir) - if (packageJson.dependencies && !Object.keys(packageJson.dependencies).includes(NETLIFY_NEON_PACKAGE_NAME)) { - await spawnAsync(command.project.packageManager?.installCommand ?? 'npm install', ['@netlify/neon@latest'], { - stdio: 'inherit', - shell: true, - }) - } - } catch (e) { - console.error(`Failed to install @netlify/neon in ${command.workingDir}:`, e) - } - const siteInfo = command.netlify.siteInfo as SiteInfo if (!command.siteId) { console.error(`The project must be linked with netlify link before initializing a database.`) @@ -159,6 +147,21 @@ export const init = async (_options: OptionValues, command: BaseCommand) => { console.error('Failed to get database status', e) } + try { + const packageJson = getPackageJSON(command.workingDir) + if ( + (packageJson.dependencies && !Object.keys(packageJson.dependencies).includes(NETLIFY_NEON_PACKAGE_NAME)) || + !packageJson.dependencies + ) { + await spawnAsync(command.project.packageManager?.installCommand ?? 'npm install', ['@netlify/neon@latest'], { + stdio: 'inherit', + shell: true, + }) + } + } catch (e) { + console.error(`Failed to install @netlify/neon in ${command.workingDir}:`, e) + } + log( prettyjson.render({ 'Current team': account.name, From 255457f9975317628b3f7783c14b8fb017854e58 Mon Sep 17 00:00:00 2001 From: Caleb Barnes Date: Tue, 20 May 2025 13:53:14 -0700 Subject: [PATCH 22/22] add comment to drizzle config boilerplate for context --- src/commands/database/drizzle.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/commands/database/drizzle.ts b/src/commands/database/drizzle.ts index 742378ad53b..2cf7bb15849 100644 --- a/src/commands/database/drizzle.ts +++ b/src/commands/database/drizzle.ts @@ -79,6 +79,10 @@ export default defineConfig({ url: process.env.NETLIFY_DATABASE_URL! }, schema: './db/schema.ts', + /** + * Never edit the migrations directly, only use drizzle. + * There are scripts in the package.json "db:generate" and "db:migrate" to handle this. + */ out: './migrations' });`