Skip to content

Commit aefd8b7

Browse files
author
Caleb Barnes
committed
POC adding database init command / drizzle-kit as an external subcommand
1 parent 1978faf commit aefd8b7

File tree

4 files changed

+381
-0
lines changed

4 files changed

+381
-0
lines changed

src/commands/database/database.ts

Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
1+
import fs from 'fs'
2+
import path from 'path'
3+
import { OptionValues } from 'commander'
4+
import BaseCommand from '../base-command.js'
5+
6+
import openBrowser from '../../utils/open-browser.js'
7+
import { getExtension, getExtensionInstallations, installExtension } from './utils.js'
8+
import { getToken } from '../../utils/command-helpers.js'
9+
import inquirer from 'inquirer'
10+
import { NetlifyAPI } from 'netlify'
11+
import { spawn } from 'child_process'
12+
13+
const NETLIFY_DATABASE_EXTENSION_SLUG = '-94w9m6w-netlify-database-extension'
14+
15+
const init = async (_options: OptionValues, command: BaseCommand) => {
16+
process.env.UNSTABLE_NETLIFY_DATABASE_EXTENSION_HOST_SITE_URL = 'http://localhost:8989'
17+
18+
if (!command.siteId) {
19+
console.error(`The project must be linked with netlify link before initializing a database.`)
20+
return
21+
}
22+
23+
const initialOpts = command.opts()
24+
25+
const answers = await inquirer.prompt(
26+
[
27+
{
28+
type: 'confirm',
29+
name: 'drizzle',
30+
message: 'Use Drizzle?',
31+
},
32+
].filter((q) => !initialOpts[q.name]),
33+
)
34+
35+
if (!initialOpts.drizzle) {
36+
command.setOptionValue('drizzle', answers.drizzle)
37+
}
38+
const opts = command.opts()
39+
40+
if (opts.drizzle && command.project.root) {
41+
const drizzleConfigFilePath = path.resolve(command.project.root, 'drizzle.config.ts')
42+
await carefullyWriteFile(drizzleConfigFilePath, drizzleConfig)
43+
44+
fs.mkdirSync(path.resolve(command.project.root, 'db'), { recursive: true })
45+
const schemaFilePath = path.resolve(command.project.root, 'db/schema.ts')
46+
await carefullyWriteFile(schemaFilePath, exampleDrizzleSchema)
47+
48+
const dbIndexFilePath = path.resolve(command.project.root, 'db/index.ts')
49+
await carefullyWriteFile(dbIndexFilePath, exampleDbIndex)
50+
51+
console.log('Adding drizzle-kit and drizzle-orm to the project')
52+
// install dev deps
53+
const devDepProc = spawn(
54+
command.project.packageManager?.installCommand ?? 'npm install',
55+
['drizzle-kit@latest', '-D'],
56+
{
57+
stdio: 'inherit',
58+
shell: true,
59+
},
60+
)
61+
devDepProc.on('exit', (code) => {
62+
if (code === 0) {
63+
// install deps
64+
spawn(command.project.packageManager?.installCommand ?? 'npm install', ['drizzle-orm@latest'], {
65+
stdio: 'inherit',
66+
shell: true,
67+
})
68+
}
69+
})
70+
}
71+
72+
let site: Awaited<ReturnType<typeof command.netlify.api.getSite>>
73+
try {
74+
// @ts-expect-error -- feature_flags is not in the types
75+
site = await command.netlify.api.getSite({ siteId: command.siteId, feature_flags: 'cli' })
76+
} catch (e) {
77+
console.error(`Error getting site, make sure you are logged in with netlify login`, e)
78+
return
79+
}
80+
if (!site.account_id) {
81+
console.error(`Error getting site, make sure you are logged in with netlify login`)
82+
return
83+
}
84+
if (!command.netlify.api.accessToken) {
85+
console.error(`You must be logged in with netlify login to initialize a database.`)
86+
return
87+
}
88+
89+
const netlifyToken = command.netlify.api.accessToken.replace('Bearer ', '')
90+
91+
const extension = await getExtension({
92+
accountId: site.account_id,
93+
token: netlifyToken,
94+
slug: NETLIFY_DATABASE_EXTENSION_SLUG,
95+
})
96+
97+
if (!extension?.hostSiteUrl) {
98+
throw new Error(`Failed to get extension host site url when installing extension`)
99+
}
100+
101+
const installations = await getExtensionInstallations({
102+
accountId: site.account_id,
103+
siteId: command.siteId,
104+
token: netlifyToken,
105+
})
106+
const dbExtensionInstallation = (
107+
installations as {
108+
integrationSlug: string
109+
}[]
110+
).find((installation) => installation.integrationSlug === NETLIFY_DATABASE_EXTENSION_SLUG)
111+
112+
if (!dbExtensionInstallation) {
113+
console.log(`Netlify Database extension not installed on team ${site.account_id}, attempting to install now...`)
114+
115+
const installed = await installExtension({
116+
accountId: site.account_id,
117+
token: netlifyToken,
118+
slug: NETLIFY_DATABASE_EXTENSION_SLUG,
119+
hostSiteUrl: extension.hostSiteUrl ?? '',
120+
})
121+
if (!installed) {
122+
throw new Error(`Failed to install extension on team ${site.account_id}: ${NETLIFY_DATABASE_EXTENSION_SLUG}`)
123+
}
124+
console.log(`Netlify Database extension installed on team ${site.account_id}`)
125+
}
126+
127+
try {
128+
const siteEnv = await command.netlify.api.getEnvVar({
129+
accountId: site.account_id,
130+
siteId: command.siteId,
131+
key: 'NETLIFY_DATABASE_URL',
132+
})
133+
134+
if (siteEnv.key === 'NETLIFY_DATABASE_URL') {
135+
console.error(`Database already initialized for site: ${command.siteId}, skipping.`)
136+
return
137+
}
138+
} catch {
139+
// no op, env var does not exist, so we just continue
140+
}
141+
142+
console.log('Initializing a new database for site:', command.siteId)
143+
144+
const initEndpoint = new URL(
145+
'/cli-db-init',
146+
process.env.UNSTABLE_NETLIFY_DATABASE_EXTENSION_HOST_SITE_URL ?? extension.hostSiteUrl,
147+
).toString()
148+
149+
const req = await fetch(initEndpoint, {
150+
method: 'POST',
151+
headers: {
152+
'Content-Type': 'application/json',
153+
Authorization: `Bearer ${netlifyToken}`,
154+
'x-nf-db-site-id': command.siteId,
155+
'x-nf-db-account-id': site.account_id,
156+
},
157+
})
158+
159+
const res = await req.json()
160+
console.log(res)
161+
return
162+
}
163+
164+
export const createDatabaseCommand = (program: BaseCommand) => {
165+
const dbCommand = program.command('db').alias('database').description(`TODO: write description for database command`)
166+
167+
dbCommand
168+
.command('init')
169+
.description('Initialize a new database')
170+
.option('--drizzle', 'Sets up drizzle-kit and drizzle-orm in your project')
171+
.action(init)
172+
173+
dbCommand
174+
.command('drizzle-kit', 'TODO: write description for drizzle-kit command', {
175+
executableFile: path.resolve(program.workingDir, './node_modules/drizzle-kit/bin.cjs'),
176+
})
177+
.option('--open', 'when running drizzle-kit studio, open the browser to the studio url')
178+
.hook('preSubcommand', async (thisCommand, actionCommand) => {
179+
if (actionCommand.name() === 'drizzle-kit') {
180+
// @ts-expect-error thisCommand is not assignable to BaseCommand
181+
await drizzleKitPreAction(thisCommand) // set the NETLIFY_DATABASE_URL env var before drizzle-kit runs
182+
}
183+
})
184+
.allowUnknownOption() // allow unknown options to be passed through to drizzle-kit executable
185+
186+
return dbCommand
187+
}
188+
189+
const drizzleKitPreAction = async (thisCommand: BaseCommand) => {
190+
const opts = thisCommand.opts()
191+
const workingDir = thisCommand.workingDir
192+
const drizzleKitBinPath = path.resolve(workingDir, './node_modules/drizzle-kit/bin.cjs')
193+
try {
194+
fs.statSync(drizzleKitBinPath)
195+
} catch {
196+
console.error(`drizzle-kit not found in project's node modules, make sure you have installed drizzle-kit.`)
197+
return
198+
}
199+
200+
const rawState = fs.readFileSync(path.resolve(workingDir, '.netlify/state.json'), 'utf8')
201+
const state = JSON.parse(rawState) as { siteId?: string } | undefined
202+
if (!state?.siteId) {
203+
throw new Error(`No site id found in .netlify/state.json`)
204+
}
205+
206+
const [token] = await getToken()
207+
if (!token) {
208+
throw new Error(`No token found, please login with netlify login`)
209+
}
210+
const client = new NetlifyAPI(token)
211+
let site
212+
try {
213+
site = await client.getSite({ siteId: state.siteId })
214+
} catch {
215+
throw new Error(`No site found for site id ${state.siteId}`)
216+
}
217+
const accountId = site.account_id
218+
if (!accountId) {
219+
throw new Error(`No account id found for site ${state.siteId}`)
220+
}
221+
222+
let netlifyDatabaseEnv
223+
try {
224+
netlifyDatabaseEnv = await client.getEnvVar({
225+
siteId: state.siteId,
226+
accountId,
227+
key: 'NETLIFY_DATABASE_URL',
228+
})
229+
} catch {
230+
throw new Error(
231+
`NETLIFY_DATABASE_URL environment variable not found on site ${state.siteId}. Run \`netlify db init\` first.`,
232+
)
233+
}
234+
235+
const NETLIFY_DATABASE_URL = netlifyDatabaseEnv.values?.find(
236+
(val) => val.context === 'all' || val.context === 'dev',
237+
)?.value
238+
239+
if (!NETLIFY_DATABASE_URL) {
240+
console.error(`NETLIFY_DATABASE_URL environment variable not found in project settings.`)
241+
return
242+
}
243+
244+
if (typeof NETLIFY_DATABASE_URL === 'string') {
245+
process.env.NETLIFY_DATABASE_URL = NETLIFY_DATABASE_URL
246+
if (opts.open) {
247+
await openBrowser({ url: 'https://local.drizzle.studio/', silentBrowserNoneError: true })
248+
}
249+
}
250+
}
251+
252+
const drizzleConfig = `import { defineConfig } from 'drizzle-kit';
253+
254+
export default defineConfig({
255+
dialect: 'postgresql',
256+
dbCredentials: {
257+
url: process.env.NETLIFY_DATABASE_URL!
258+
},
259+
schema: './db/schema.ts'
260+
});`
261+
262+
const exampleDrizzleSchema = `import { integer, pgTable, varchar, text } from 'drizzle-orm/pg-core';
263+
264+
export const post = pgTable('post', {
265+
id: integer().primaryKey().generatedAlwaysAsIdentity(),
266+
title: varchar({ length: 255 }).notNull(),
267+
content: text().notNull().default('')
268+
});
269+
`
270+
271+
const exampleDbIndex = `import { drizzle } from 'lib/db';
272+
// import { drizzle } from '@netlify/database'
273+
import * as schema from 'db/schema';
274+
275+
export const db = drizzle({
276+
schema
277+
});
278+
`
279+
280+
const carefullyWriteFile = async (filePath: string, data: string) => {
281+
if (fs.existsSync(filePath)) {
282+
const answers = await inquirer.prompt([
283+
{
284+
type: 'confirm',
285+
name: 'overwrite',
286+
message: `Overwrite existing ${path.basename(filePath)}?`,
287+
},
288+
])
289+
if (answers.overwrite) {
290+
fs.writeFileSync(filePath, data)
291+
}
292+
} else {
293+
fs.writeFileSync(filePath, data)
294+
}
295+
}

src/commands/database/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { createDatabaseCommand as createDevCommand } from './database.js'

src/commands/database/utils.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
const JIGSAW_URL = 'https://jigsaw.services-prod.nsvcs.net'
2+
3+
export const getExtensionInstallations = async ({
4+
siteId,
5+
accountId,
6+
token,
7+
}: {
8+
siteId: string
9+
accountId: string
10+
token: string
11+
}) => {
12+
const installationsResponse = await fetch(
13+
`${JIGSAW_URL}/team/${encodeURIComponent(accountId)}/integrations/installations/${encodeURIComponent(siteId)}`,
14+
{
15+
headers: {
16+
'netlify-token': token,
17+
},
18+
},
19+
)
20+
21+
if (!installationsResponse.ok) {
22+
return new Response('Failed to fetch installed extensions for site', {
23+
status: 500,
24+
})
25+
}
26+
27+
const installations = await installationsResponse.json()
28+
// console.log('installations', installations)
29+
return installations
30+
}
31+
32+
export const getExtension = async ({ accountId, token, slug }: { accountId: string; token: string; slug: string }) => {
33+
const fetchExtensionUrl = new URL('/.netlify/functions/fetch-extension', 'https://app.netlify.com/')
34+
fetchExtensionUrl.searchParams.append('teamId', accountId)
35+
fetchExtensionUrl.searchParams.append('slug', slug)
36+
37+
const extensionReq = await fetch(fetchExtensionUrl.toString(), {
38+
headers: {
39+
Cookie: `_nf-auth=${token}`,
40+
},
41+
})
42+
const extension = (await extensionReq.json()) as
43+
| {
44+
hostSiteUrl?: string
45+
}
46+
| undefined
47+
48+
return extension
49+
}
50+
51+
export const installExtension = async ({
52+
token,
53+
accountId,
54+
slug,
55+
hostSiteUrl,
56+
}: {
57+
token: string
58+
accountId: string
59+
slug: string
60+
hostSiteUrl: string
61+
}) => {
62+
const installExtensionResponse = await fetch(`https://app.netlify.com/.netlify/functions/install-extension`, {
63+
method: 'POST',
64+
headers: {
65+
'Content-Type': 'application/json',
66+
Cookie: `_nf-auth=${token}`,
67+
},
68+
body: JSON.stringify({
69+
teamId: accountId,
70+
slug,
71+
hostSiteUrl,
72+
}),
73+
})
74+
75+
if (!installExtensionResponse.ok) {
76+
throw new Error(`Failed to install extension: ${slug}`)
77+
}
78+
79+
const installExtensionData = await installExtensionResponse.json()
80+
console.log('installExtensionData', installExtensionData)
81+
82+
return installExtensionData
83+
}

src/commands/main.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import { AddressInUseError } from './types.js'
4747
import { createUnlinkCommand } from './unlink/index.js'
4848
import { createWatchCommand } from './watch/index.js'
4949
import terminalLink from 'terminal-link'
50+
import { createDatabaseCommand } from './database/database.js'
5051

5152
const SUGGESTION_TIMEOUT = 1e4
5253

@@ -231,6 +232,7 @@ export const createMainCommand = (): BaseCommand => {
231232
createUnlinkCommand(program)
232233
createWatchCommand(program)
233234
createLogsCommand(program)
235+
createDatabaseCommand(program)
234236

235237
program.setAnalyticsPayload({ didEnableCompileCache })
236238

0 commit comments

Comments
 (0)