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 fd4effc8b99..f3323dd02a7 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 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, diff --git a/src/commands/database/constants.ts b/src/commands/database/constants.ts new file mode 100644 index 00000000000..8cb73b60c3d --- /dev/null +++ b/src/commands/database/constants.ts @@ -0,0 +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' diff --git a/src/commands/database/database.ts b/src/commands/database/database.ts new file mode 100644 index 00000000000..978e198be04 --- /dev/null +++ b/src/commands/database/database.ts @@ -0,0 +1,43 @@ +import BaseCommand from '../base-command.js' +import { status } from './status.js' +import { init } 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 +} + +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(`--drizzle`, 'Initialize basic drizzle config and schema boilerplate') + .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) +} diff --git a/src/commands/database/drizzle.ts b/src/commands/database/drizzle.ts new file mode 100644 index 00000000000..8f934394a95 --- /dev/null +++ b/src/commands/database/drizzle.ts @@ -0,0 +1,125 @@ +import { spawn } from 'child_process' +import { carefullyWriteFile } from './utils.js' +import BaseCommand from '../base-command.js' +import path from 'path' +import fs from 'fs/promises' +import inquirer from 'inquirer' + +export const initDrizzle = async (command: BaseCommand) => { + if (!command.project.root) { + throw new Error('Failed to initialize Drizzle in project. Project root not found.') + } + const opts = command.opts<{ + overwrite?: true | undefined + }>() + + 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, 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, drizzleSchema, command.project.root) + await carefullyWriteFile(dbIndexFilePath, dbIndex, 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)) + } + + type Answers = { + updatePackageJson: boolean + } + + if (!opts.overwrite) { + const answers = await inquirer.prompt([ + { + type: 'confirm', + name: 'updatePackageJson', + message: `Add drizzle db commands to package.json?`, + }, + ]) + if (answers.updatePackageJson) { + await fs.writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2)) + } + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access + if (!Object.keys(packageJson?.devDependencies ?? {}).includes('drizzle-kit')) { + await spawnAsync(command.project.packageManager?.installCommand ?? 'npm install', ['drizzle-kit@latest', '-D'], { + stdio: 'inherit', + shell: true, + }) + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access + if (!Object.keys(packageJson?.dependencies ?? {}).includes('drizzle-orm')) { + await spawnAsync(command.project.packageManager?.installCommand ?? 'npm install', ['drizzle-orm@latest'], { + stdio: 'inherit', + shell: true, + }) + } +} + +const drizzleConfig = `import { defineConfig } from 'drizzle-kit'; + +export default defineConfig({ + dialect: 'postgresql', + dbCredentials: { + url: process.env.NETLIFY_DATABASE_URL! + }, + schema: './db/schema.ts', + out: './migrations' +});` + +const 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'; +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', +} + +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/index.ts b/src/commands/database/index.ts new file mode 100644 index 00000000000..27d2ca25f54 --- /dev/null +++ b/src/commands/database/index.ts @@ -0,0 +1 @@ +export { createDatabaseCommand } from './database.js' diff --git a/src/commands/database/init.ts b/src/commands/database/init.ts new file mode 100644 index 00000000000..665a5344786 --- /dev/null +++ b/src/commands/database/init.ts @@ -0,0 +1,166 @@ +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() + + const opts = command.opts<{ + drizzle?: boolean | 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 ', '') + + 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) { + 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) { + const answers = await inquirer.prompt<{ + drizzle: boolean + }>([ + { + type: 'confirm', + name: 'drizzle', + message: 'Use Drizzle?', + }, + ]) + command.setOptionValue('drizzle', answers.drizzle) + } + if (opts.drizzle) { + 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 + } + 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'}`) + } + } + + let status + + 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( + prettyjson.render({ + 'Current team': account.name, + 'Current site': siteInfo.name, + [`${extension.name} extension`]: 'installed on team', + ['Database status']: status?.siteConfiguration?.connectedDatabase?.isConnected + ? 'connected to site' + : 'not connected', + ['Environment variables']: '', + [' 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 +} 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 new file mode 100644 index 00000000000..7c25e36c587 --- /dev/null +++ b/src/commands/database/utils.ts @@ -0,0 +1,219 @@ +import fsPromises from 'fs/promises' +import fs from 'fs' +import inquirer from 'inquirer' + +import { JIGSAW_URL } from './constants.js' +import BaseCommand from '../base-command.js' +import { Extension } from './database.js' + +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', + }, + }, + ) + 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 ({ + netlifyToken, + accountId, + slug, + hostSiteUrl, +}: { + netlifyToken: string + accountId: string + slug: string + hostSiteUrl: string +}) => { + 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', + body: JSON.stringify({ + teamId: accountId, + }), + headers: { + 'netlify-token': jigsawToken, + }, + }) + + if (!installedResponse.ok && installedResponse.status !== 409) { + const text = await installedResponse.text() + throw new Error(`Failed to install extension '${slug}': ${text}`) + } + return true +} + +export const getSiteConfiguration = async ({ + siteId, + accountId, + netlifyToken, + slug, +}: { + siteId: string + accountId: 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': 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 +} + +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' + > +} + +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`, + }, + } + } +} diff --git a/src/commands/main.ts b/src/commands/main.ts index 3ba273a8da8..1fb721af4a1 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/index.js' const SUGGESTION_TIMEOUT = 1e4 @@ -233,6 +234,7 @@ export const createMainCommand = (): BaseCommand => { createUnlinkCommand(program) createWatchCommand(program) createLogsCommand(program) + createDatabaseCommand(program) program.setAnalyticsPayload({ didEnableCompileCache }) diff --git a/tests/integration/commands/help/__snapshots__/help.test.ts.snap b/tests/integration/commands/help/__snapshots__/help.test.ts.snap index c1eafadb594..3d15aea9bb4 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 site 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 site