Skip to content

feat: Add db init and db status commands #7115

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 33 commits into from
Jun 5, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
a33288e
POC adding database init command / drizzle-kit as an external subcommand
Mar 20, 2025
fffd673
cleanup
Mar 20, 2025
4f44728
cleanup
Mar 24, 2025
759fccb
fix awaiting drizzle deps installing / add status check and getSiteCo…
Mar 24, 2025
dd1699d
fix request headers for init endpoint / misc cleanup
Mar 25, 2025
107faf7
only inquirer.prompt if initialOpts.drizzle is not explicitly passed …
Mar 25, 2025
2fd7ca0
move urls to constants with env overrides / cleanup logging / remove …
Mar 25, 2025
b9e566d
remove commented unused call to get site
Mar 25, 2025
5b08ee3
remove unnecessary check
Mar 25, 2025
115e19f
fix: change slug into the neon slug
May 1, 2025
c4155cd
Update db init command (#7257)
May 2, 2025
c0459ac
update db command help text
May 2, 2025
f30f195
update command descriptions and examples
May 2, 2025
836fd29
update help command snapshot
May 2, 2025
254cc6d
support docs gen for sub commands without ":"
May 2, 2025
9085a8f
docs gen for db commands
May 2, 2025
dbb78c7
remove "yes" flag and add "minimal" flag
May 2, 2025
e151484
improve db init ux - dont throw error on CONFLICT (db already connected)
May 5, 2025
cadddbc
install @netlify/neon package if not found in package.json
May 14, 2025
6ba266a
use same package json path
May 20, 2025
a2d9483
move neon package installation to end to avoid incorrect overwriting
May 20, 2025
255457f
add comment to drizzle config boilerplate for context
May 20, 2025
191fc80
Merge branch 'main' into feat/netlify-database-command
May 21, 2025
41d0739
fix: initDrizzle - fallback to command.project.baseDirectory when com…
CalebBarnes Jun 4, 2025
b382c46
perf: lazy-load `netlify db` commands
ndhoule Jun 4, 2025
144d8b1
Update src/commands/database/constants.ts
ndhoule Jun 4, 2025
d8060f3
feat: get user to initialize site first if they have neon installed f…
sarahetter Jun 5, 2025
0a99fba
feat: rename --minimal => --assume-no, --drizzle => --boilerplate=<type>
ndhoule Jun 5, 2025
33475e2
docs: update docs with new db flags
ndhoule Jun 5, 2025
d819f71
fix: update db init example to not use --minimal
ndhoule Jun 5, 2025
a05e85f
docs: regenerate docs again
ndhoule Jun 5, 2025
a0a71f0
Merge branch 'main' into feat/netlify-database-command
khendrikse Jun 5, 2025
501bb66
Merge branch 'main' into feat/netlify-database-command
khendrikse Jun 5, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions docs/commands/db.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
---
title: Netlify CLI db command
description: Provision a production ready Postgres database with a single command
sidebar:
label: db
---

# `db`


<!-- AUTO-GENERATED-CONTENT:START (GENERATE_COMMANDS_DOCS) -->
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 |

Check warning on line 26 in docs/commands/db.md

View workflow job for this annotation

GitHub Actions / lint-docs

[vale] reported by reviewdog 🐶 [base.spelling] Spellcheck: did you really mean 'Subcommand'? Raw Output: {"message": "[base.spelling] Spellcheck: did you really mean 'Subcommand'?", "location": {"path": "docs/commands/db.md", "range": {"start": {"line": 26, "column": 3}}}, "severity": "WARNING"}
|:--------------------------- |:-----|
| [`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**

- `assume-no` (*boolean*) - Non-interactive setup. Does not initialize any third-party tools/boilerplate. Ideal for CI environments or AI tools.
- `boilerplate` (*drizzle*) - Type of boilerplate to add to your project.
- `filter` (*string*) - For monorepos, specify the name of the application to run the command in
- `overwrite` (*boolean*) - Overwrites existing files that would be created when setting up boilerplate
- `debug` (*boolean*) - Print debugging information
- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in
- `no-boilerplate` (*boolean*) - Don't add any boilerplate to your project.

Check warning on line 59 in docs/commands/db.md

View workflow job for this annotation

GitHub Actions / lint-docs

[vale] reported by reviewdog 🐶 [smart-marks.smartApostrophes] Use a smart apostrophe (’) instead of a straight single quote mark in 'Don't' Raw Output: {"message": "[smart-marks.smartApostrophes] Use a smart apostrophe (’) instead of a straight single quote mark in 'Don't'", "location": {"path": "docs/commands/db.md", "range": {"start": {"line": 59, "column": 34}}}, "severity": "WARNING"}

**Examples**

```bash
netlify db init --assume-no
netlify db init --boilerplate=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

---

<!-- AUTO-GENERATED-CONTENT:END -->
10 changes: 10 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,16 @@
| [`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 |

Check warning on line 58 in docs/index.md

View workflow job for this annotation

GitHub Actions / lint-docs

[vale] reported by reviewdog 🐶 [base.spelling] Spellcheck: did you really mean 'Subcommand'? Raw Output: {"message": "[base.spelling] Spellcheck: did you really mean 'Subcommand'?", "location": {"path": "docs/index.md", "range": {"start": {"line": 58, "column": 3}}}, "severity": "WARNING"}
|:--------------------------- |:-----|
| [`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
Expand Down
6 changes: 5 additions & 1 deletion site/scripts/docs.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
9 changes: 5 additions & 4 deletions site/scripts/util/generate-command-data.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions src/commands/base-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { findUp } from 'find-up'
import inquirer from 'inquirer'
import inquirerAutocompletePrompt from 'inquirer-autocomplete-prompt'
import merge from 'lodash/merge.js'
import pick from 'lodash/pick.js'

import { getAgent } from '../lib/http-agent.js'
import {
Expand Down Expand Up @@ -785,3 +786,6 @@ export default class BaseCommand extends Command {
return this.netlify.siteInfo.feature_flags?.[flagName] || null
}
}

export const getBaseOptionValues = (options: OptionValues): BaseOptionValues =>
pick(options, ['auth', 'cwd', 'debug', 'filter', 'httpProxy', 'silent'])
3 changes: 3 additions & 0 deletions src/commands/database/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const NEON_DATABASE_EXTENSION_SLUG = process.env.NEON_DATABASE_EXTENSION_SLUG ?? 'neon'
export const JIGSAW_URL = process.env.JIGSAW_URL ?? 'https://jigsaw.services-prod.nsvcs.net'
export const NETLIFY_NEON_PACKAGE_NAME = '@netlify/neon'
83 changes: 83 additions & 0 deletions src/commands/database/database.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { Option } from 'commander'
import inquirer from 'inquirer'
import BaseCommand from '../base-command.js'
import type { DatabaseBoilerplateType, DatabaseInitOptions } from './init.js'

export type Extension = {
id: string
name: string
slug: string
hostSiteUrl: string
installedOnTeam: boolean
}

export type SiteInfo = {
id: string
name: string
account_id: string
admin_url: string
url: string
ssl_url: string
}

const supportedBoilerplates = new Set<DatabaseBoilerplateType>(['drizzle'])

export const createDatabaseCommand = (program: BaseCommand) => {
const dbCommand = program
.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 --help'])

dbCommand
.command('init')
.description(`Initialize a new database for the current site`)
.option(
'--assume-no',
'Non-interactive setup. Does not initialize any third-party tools/boilerplate. Ideal for CI environments or AI tools.',
false,
)
.addOption(
new Option('--boilerplate <tool>', 'Type of boilerplate to add to your project.').choices(
Array.from(supportedBoilerplates).sort(),
),
)
.option('--no-boilerplate', "Don't add any boilerplate to your project.")
.option('-o, --overwrite', 'Overwrites existing files that would be created when setting up boilerplate')
.action(async (_options: Record<string, unknown>, command: BaseCommand) => {
const { init } = await import('./init.js')

// Only prompt for drizzle if the user did not specify a boilerplate option, and if we're in
// interactive mode
if (_options.boilerplate === undefined && !_options.assumeNo) {
const answers = await inquirer.prompt<{ useDrizzle: boolean }>([
{
type: 'confirm',
name: 'useDrizzle',
message: 'Set up Drizzle boilerplate?',
},
])
if (answers.useDrizzle) {
command.setOptionValue('boilerplate', 'drizzle')
}
}

const options = _options as DatabaseInitOptions
if (options.assumeNo) {
options.boilerplate = false
options.overwrite = false
}

await init(options, command)
})
.addExamples([`netlify db init --assume-no`, `netlify db init --boilerplate=drizzle --overwrite`])

dbCommand
.command('status')
.description(`Check the status of the database`)
.action(async (options, command) => {
const { status } = await import('./status.js')
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
await status(options, command)
})
}
112 changes: 112 additions & 0 deletions src/commands/database/drizzle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
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) => {
const workingDirectory = command.project.root ?? command.project.baseDirectory
if (!workingDirectory) {
throw new Error('Failed to initialize Drizzle. Project root or base directory not found.')
}
const opts = command.opts<{
overwrite?: true | undefined
}>()

const drizzleConfigFilePath = path.resolve(workingDirectory, 'drizzle.config.ts')
const schemaFilePath = path.resolve(workingDirectory, 'db/schema.ts')
const dbIndexFilePath = path.resolve(workingDirectory, 'db/index.ts')
if (opts.overwrite) {
await fs.writeFile(drizzleConfigFilePath, drizzleConfig)
await fs.mkdir(path.resolve(workingDirectory, 'db'), { recursive: true })
await fs.writeFile(schemaFilePath, drizzleSchema)
await fs.writeFile(dbIndexFilePath, dbIndex)
} else {
await carefullyWriteFile(drizzleConfigFilePath, drizzleConfig, workingDirectory)
await fs.mkdir(path.resolve(workingDirectory, 'db'), { recursive: true })
await carefullyWriteFile(schemaFilePath, drizzleSchema, workingDirectory)
await carefullyWriteFile(dbIndexFilePath, dbIndex, workingDirectory)
}

const packageJsonPath = path.resolve(command.workingDir, 'package.json')
const packageJson = getPackageJSON(command.workingDir)

packageJson.scripts = {
...(packageJson.scripts ?? {}),
...packageJsonScripts,
}
if (opts.overwrite) {
await fs.writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2))
}

type Answers = {
updatePackageJson: boolean
}

if (!opts.overwrite) {
const answers = await inquirer.prompt<Answers>([
{
type: 'confirm',
name: 'updatePackageJson',
message: `Add drizzle db commands to package.json?`,
},
])
if (answers.updatePackageJson) {
await fs.writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2))
}
}

if (!Object.keys(packageJson.devDependencies ?? {}).includes('drizzle-kit')) {
await spawnAsync(command.project.packageManager?.installCommand ?? 'npm install', ['drizzle-kit@latest', '-D'], {
stdio: 'inherit',
shell: true,
})
}

if (!Object.keys(packageJson.dependencies ?? {}).includes('drizzle-orm')) {
await spawnAsync(command.project.packageManager?.installCommand ?? 'npm install', ['drizzle-orm@latest'], {
stdio: 'inherit',
shell: true,
})
}
}

const drizzleConfig = `import { defineConfig } from 'drizzle-kit';

export default defineConfig({
dialect: 'postgresql',
dbCredentials: {
url: process.env.NETLIFY_DATABASE_URL!
},
schema: './db/schema.ts',
/**
* 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'
});`

const drizzleSchema = `import { integer, pgTable, varchar, text } from 'drizzle-orm/pg-core';

export const posts = pgTable('posts', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
title: varchar({ length: 255 }).notNull(),
content: text().notNull().default('')
});`

const dbIndex = `import { neon } from '${NETLIFY_NEON_PACKAGE_NAME}';
import { drizzle } from 'drizzle-orm/neon-http';

import * as schema from './schema';

export const db = drizzle({
schema,
client: neon()
});`

const packageJsonScripts = {
'db:generate': 'drizzle-kit generate',
'db:migrate': 'netlify dev:exec drizzle-kit migrate',
'db:studio': 'netlify dev:exec drizzle-kit studio',
}
1 change: 1 addition & 0 deletions src/commands/database/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { createDatabaseCommand } from './database.js'
Loading
Loading