diff --git a/packages/db/src/core/cli/commands/link/index.ts b/packages/db/src/core/cli/commands/link/index.ts index 542782bd37..28f8cdb637 100644 --- a/packages/db/src/core/cli/commands/link/index.ts +++ b/packages/db/src/core/cli/commands/link/index.ts @@ -1,36 +1,60 @@ -import { mkdir, writeFile } from 'node:fs/promises'; import type { AstroConfig } from 'astro'; +import { slug } from 'github-slugger'; import { bgRed, cyan } from 'kleur/colors'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { basename } from 'node:path'; +import ora from 'ora'; import prompts from 'prompts'; import type { Arguments } from 'yargs-parser'; import { MISSING_SESSION_ID_ERROR } from '../../../errors.js'; import { PROJECT_ID_FILE, getSessionIdFromFile } from '../../../tokens.js'; import { getAstroStudioUrl } from '../../../utils.js'; -export async function cmd({ flags }: { config: AstroConfig; flags: Arguments }) { - const linkUrl = new URL(getAstroStudioUrl() + '/auth/cli/link'); +export async function cmd({}: { config: AstroConfig; flags: Arguments }) { const sessionToken = await getSessionIdFromFile(); if (!sessionToken) { console.error(MISSING_SESSION_ID_ERROR); process.exit(1); } - let body = { id: flags._[4] } as { - id?: string; - projectIdName?: string; - workspaceIdName?: string; - }; - if (!body.id) { - const workspaceIdName = await promptWorkspaceName(); - const projectIdName = await promptProjectName(); - body = { projectIdName, workspaceIdName }; + const getWorkspaceIdAsync = getWorkspaceId(); + await promptBegin(); + const isLinkExisting = await promptLinkExisting(); + if (isLinkExisting) { + const workspaceId = await getWorkspaceIdAsync; + const existingProjectData = await promptExistingProjectName({workspaceId}); + return await linkProject(existingProjectData.id); } + + const isLinkNew = await promptLinkNew(); + if (isLinkNew) { + const workspaceId = await getWorkspaceIdAsync; + const newProjectName = await promptNewProjectName(); + const newProjectRegion = await promptNewProjectRegion(); + const spinner = ora('Creating new project...').start(); + const newProjectData = await createNewProject({workspaceId, name: newProjectName, region: newProjectRegion}); + // TODO(fks): Actually listen for project creation before continuing + // This is just a dumb spinner that roughly matches database creation time. + await new Promise((r) => setTimeout(r, 4000)); + spinner.succeed('Project created!'); + return await linkProject(newProjectData.id); + } +} + +async function linkProject(id: string) { + await mkdir(new URL('.', PROJECT_ID_FILE), { recursive: true }); + await writeFile(PROJECT_ID_FILE, `${id}`); + console.info('Project linked.'); +} + +async function getWorkspaceId(): Promise { + const linkUrl = new URL(getAstroStudioUrl() + '/api/cli/workspaces.list'); const response = await fetch(linkUrl, { method: 'POST', headers: { Authorization: `Bearer ${await getSessionIdFromFile()}`, 'Content-Type': 'application/json', }, - body: JSON.stringify(body), }); if (!response.ok) { // Unauthorized @@ -42,38 +66,164 @@ export async function cmd({ flags }: { config: AstroConfig; flags: Arguments }) ); process.exit(1); } - - console.error(`Failed to link project: ${response.status} ${response.statusText}`); + console.error(`Failed to fetch user workspace: ${response.status} ${response.statusText}`); process.exit(1); } - const { data } = await response.json(); - await mkdir(new URL('.', PROJECT_ID_FILE), { recursive: true }); - await writeFile(PROJECT_ID_FILE, `${data.id}`); - console.info('Project linked.'); + const { data, success } = await response.json() as {success: false, data: unknown} | {success: true, data: {id: string}[]}; + if (!success) { + console.error(`Failed to fetch user's workspace.`); + process.exit(1); + } + return data[0].id; } -export async function promptProjectName(defaultName?: string): Promise { - const { projectName } = await prompts({ - type: 'text', - name: 'projectName', - message: 'Project ID', - initial: defaultName, +export async function createNewProject({workspaceId, name, region}: {workspaceId: string; name: string, region: string}) { + const linkUrl = new URL(getAstroStudioUrl() + '/api/cli/projects.create'); + const response = await fetch(linkUrl, { + method: 'POST', + headers: { + Authorization: `Bearer ${await getSessionIdFromFile()}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ workspaceId, name, region }), }); - if (typeof projectName !== 'string') { - process.exit(0); + if (!response.ok) { + // Unauthorized + if (response.status === 401) { + console.error( + `${bgRed('Unauthorized')}\n\n Are you logged in?\n Run ${cyan( + 'astro db login' + )} to authenticate and then try linking again.\n\n` + ); + process.exit(1); + } + console.error(`Failed to create project: ${response.status} ${response.statusText}`); + process.exit(1); } - return projectName; + const { data, success } = await response.json() as {success: false, data: unknown} | {success: true, data: {id: string; idName: string}}; + if (!success) { + console.error(`Failed to create project.`); + process.exit(1); + } + return {id: data.id, idName: data.idName}; } -export async function promptWorkspaceName(defaultName?: string): Promise { - const { workspaceName } = await prompts({ - type: 'text', - name: 'workspaceName', - message: 'Workspace ID', - initial: defaultName, +export async function promptExistingProjectName({workspaceId}: {workspaceId: string}) { + const linkUrl = new URL(getAstroStudioUrl() + '/api/cli/projects.list'); + const response = await fetch(linkUrl, { + method: 'POST', + headers: { + Authorization: `Bearer ${await getSessionIdFromFile()}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({workspaceId}), }); - if (typeof workspaceName !== 'string') { + if (!response.ok) { + // Unauthorized + if (response.status === 401) { + console.error( + `${bgRed('Unauthorized')}\n\n Are you logged in?\n Run ${cyan( + 'astro db login' + )} to authenticate and then try linking again.\n\n` + ); + process.exit(1); + } + console.error(`Failed to fetch projects: ${response.status} ${response.statusText}`); + process.exit(1); + } + const { data, success } = await response.json() as {success: false, data: unknown} | {success: true, data: {id: string; idName: string}[]}; + if (!success) { + console.error(`Failed to fetch projects.`); + process.exit(1); + } + const { projectId } = await prompts({ + type: 'autocomplete', + name: 'projectId', + message: 'What is your project name?', + limit: 5, + choices: data.map((p: any) => ({title: p.name, value: p.id})), + }); + if (typeof projectId !== 'string') { + console.log('Canceled.') process.exit(0); } - return workspaceName; + const selectedProjectData = data.find((p: any) => p.id === projectId)!; + return selectedProjectData; } + +export async function promptBegin(): Promise { + // Get the current working directory relative to the user's home directory + const prettyCwd = process.cwd().replace(homedir(), '~'); + + // prompt + const { begin } = await prompts({ + type: 'confirm', + name: 'begin', + message: `Link "${prettyCwd}" with Astro Studio?`, + initial: true, + }); + if (!begin) { + console.log('Canceled.') + process.exit(0); + }; +} + +export async function promptLinkExisting(): Promise { + // prompt + const { linkExisting } = await prompts({ + type: 'confirm', + name: 'linkExisting', + message: `Link with an existing project in Astro Studio?`, + initial: true, + }); + return !!linkExisting; +} + +export async function promptLinkNew(): Promise { + // prompt + const { linkNew } = await prompts({ + type: 'confirm', + name: 'linkNew', + message: `Create a new project in Astro Studio?`, + initial: true, + }); + if (!linkNew) { + console.log('Canceled.') + process.exit(0); + }; + return true; +} + + +export async function promptNewProjectName(): Promise { + const { newProjectName } = await prompts({ + type: 'text', + name: 'newProjectName', + message: `What is your new project's name?`, + initial: basename(process.cwd()), + format: (val) => slug(val), + }); + if (!newProjectName) { + console.log('Canceled.') + process.exit(0); + }; + return newProjectName; +} + +export async function promptNewProjectRegion(): Promise { + const { newProjectRegion } = await prompts({ + type: 'select', + name: 'newProjectRegion', + message: `Where should your new database live?`, + choices: [ + {title: 'North America (East)', value: 'NorthAmericaEast'}, + {title: 'North America (West)', value: 'NorthAmericaWest'} + ], + initial: 0, + }); + if (!newProjectRegion) { + console.log('Canceled.') + process.exit(0); + }; + return newProjectRegion; +} \ No newline at end of file