diff --git a/packages/db/package.json b/packages/db/package.json index 92ac29998e..38ba2f4615 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -59,17 +59,19 @@ "drizzle-orm": "^0.28.6", "kleur": "^4.1.5", "nanoid": "^5.0.1", + "open": "^10.0.3", + "ora": "^7.0.1", "prompts": "^2.4.2", "yargs-parser": "^21.1.1", "zod": "^3.22.4" }, "devDependencies": { + "@types/chai": "^4.3.6", "@types/deep-diff": "^1.0.5", "@types/diff": "^5.0.8", - "@types/yargs-parser": "^21.0.3", - "@types/chai": "^4.3.6", "@types/mocha": "^10.0.2", "@types/prompts": "^2.4.8", + "@types/yargs-parser": "^21.0.3", "astro": "workspace:*", "astro-scripts": "workspace:*", "chai": "^4.3.10", diff --git a/packages/db/src/core/cli/commands/link/index.ts b/packages/db/src/core/cli/commands/link/index.ts new file mode 100644 index 0000000000..0cdb76e41b --- /dev/null +++ b/packages/db/src/core/cli/commands/link/index.ts @@ -0,0 +1,61 @@ +import type { AstroConfig } from 'astro'; +import { mkdir, writeFile } from 'node:fs/promises'; +import prompts from 'prompts'; +import type { Arguments } from 'yargs-parser'; +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'); + const sessionToken = await getSessionIdFromFile(); + if (!sessionToken) { + console.error('You must be logged in to link a project.'); + process.exit(1); + } + + const workspaceIdName = await promptWorkspaceName(); + const projectIdName = await promptProjectName(); + + const response = await fetch(linkUrl, { + method: 'POST', + headers: { + Authorization: `Bearer ${await getSessionIdFromFile()}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({projectIdName, workspaceIdName}) + }); + if (!response.ok) { + console.error(`Failed to link project: ${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.'); +} + +export async function promptProjectName(defaultName?: string): Promise { + const { projectName } = await prompts({ + type: 'text', + name: 'projectName', + message: 'Project ID', + initial: defaultName, + }); + if (typeof projectName !== 'string') { + process.exit(0); + } + return projectName; +} + +export async function promptWorkspaceName(defaultName?: string): Promise { + const { workspaceName } = await prompts({ + type: 'text', + name: 'workspaceName', + message: 'Workspace ID', + initial: defaultName, + }); + if (typeof workspaceName !== 'string') { + process.exit(0); + } + return workspaceName; +} diff --git a/packages/db/src/core/cli/commands/login/index.ts b/packages/db/src/core/cli/commands/login/index.ts new file mode 100644 index 0000000000..ff5406bb92 --- /dev/null +++ b/packages/db/src/core/cli/commands/login/index.ts @@ -0,0 +1,53 @@ +import type { AstroConfig } from 'astro'; +import { cyan } from 'kleur/colors'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import ora from 'ora'; +import type { Arguments } from 'yargs-parser'; +import { getAstroStudioUrl } from '../../../utils.js'; +import open from 'open'; +import { SESSION_LOGIN_FILE } from '../../../tokens.js'; + +function serveAndResolveSession(): Promise { + let resolve: (value: string | PromiseLike) => void, + reject: (value?: string | PromiseLike) => void; + const sessionPromise = new Promise((_resolve, _reject) => { + resolve = _resolve; + reject = _reject; + }); + + const server = createServer((req, res) => { + res.writeHead(200); + res.end(); + const url = new URL(req.url ?? '/', `http://${req.headers.host}`); + const session = url.searchParams.get('session'); + if (!session) { + reject(); + } else { + resolve(session); + } + }).listen(5710, 'localhost'); + + return sessionPromise.finally(() => { + server.closeAllConnections(); + server.close(); + }); +} + +export async function cmd({ flags }: { config: AstroConfig; flags: Arguments }) { + let session = flags.session; + const loginUrl = getAstroStudioUrl() + '/auth/cli'; + + if (!session) { + console.log(`Opening ${cyan(loginUrl)} in your browser...`); + console.log(`If something goes wrong, copy-and-paste the URL into your browser.`); + open(loginUrl); + const spinner = ora('Waiting for confirmation...'); + session = await serveAndResolveSession(); + spinner.succeed('Successfully logged in!'); + } + + await mkdir(new URL('.', SESSION_LOGIN_FILE), { recursive: true }); + await writeFile(SESSION_LOGIN_FILE, `${session}`); + console.log('Logged in 🚀'); +} diff --git a/packages/db/src/core/cli/commands/logout/index.ts b/packages/db/src/core/cli/commands/logout/index.ts new file mode 100644 index 0000000000..d84d5abaa2 --- /dev/null +++ b/packages/db/src/core/cli/commands/logout/index.ts @@ -0,0 +1,9 @@ +import type { AstroConfig } from 'astro'; +import { unlink } from 'node:fs/promises'; +import type { Arguments } from 'yargs-parser'; +import { SESSION_LOGIN_FILE } from '../../../tokens.js'; + +export async function cmd({ }: { config: AstroConfig; flags: Arguments }) { + await unlink(SESSION_LOGIN_FILE); + console.log('Successfully logged out of Astro Studio.'); +} diff --git a/packages/db/src/core/cli/commands/push/index.ts b/packages/db/src/core/cli/commands/push/index.ts index de1880f9d5..1c53c0066d 100644 --- a/packages/db/src/core/cli/commands/push/index.ts +++ b/packages/db/src/core/cli/commands/push/index.ts @@ -2,9 +2,15 @@ import { createClient, type InStatement } from '@libsql/client'; import type { AstroConfig } from 'astro'; import deepDiff from 'deep-diff'; import { drizzle } from 'drizzle-orm/sqlite-proxy'; +import { red } from 'kleur/colors'; +import prompts from 'prompts'; import type { Arguments } from 'yargs-parser'; -import type { AstroConfigWithDB } from '../../../types.js'; import { APP_TOKEN_ERROR } from '../../../errors.js'; +import { setupDbTables } from '../../../queries.js'; +import { getManagedAppToken } from '../../../tokens.js'; +import type { AstroConfigWithDB, DBSnapshot } from '../../../types.js'; +import { getRemoteDatabaseUrl } from '../../../utils.js'; +import { getMigrationQueries } from '../../migration-queries.js'; import { createCurrentSnapshot, createEmptySnapshot, @@ -13,19 +19,12 @@ import { loadInitialSnapshot, loadMigration, } from '../../migrations.js'; -import type { DBSnapshot } from '../../../types.js'; -import { getAstroStudioEnv, getRemoteDatabaseUrl } from '../../../utils.js'; -import { getMigrationQueries } from '../../migration-queries.js'; -import { setupDbTables } from '../../../queries.js'; -import prompts from 'prompts'; -import { red } from 'kleur/colors'; const { diff } = deepDiff; export async function cmd({ config, flags }: { config: AstroConfig; flags: Arguments }) { const isSeedData = flags.seed; const isDryRun = flags.dryRun; - const appToken = flags.token ?? getAstroStudioEnv().ASTRO_STUDIO_APP_TOKEN; const currentSnapshot = createCurrentSnapshot(config); const allMigrationFiles = await getMigrations(); if (allMigrationFiles.length === 0) { @@ -40,15 +39,18 @@ export async function cmd({ config, flags }: { config: AstroConfig; flags: Argum console.log(calculatedDiff); process.exit(1); } + + const appToken = await getManagedAppToken(flags.token); if (!appToken) { console.error(APP_TOKEN_ERROR); process.exit(1); } + // get all migrations from the filesystem const allLocalMigrations = await getMigrations(); const { data: missingMigrations } = await prepareMigrateQuery({ migrations: allLocalMigrations, - appToken, + appToken: appToken.token, }); // exit early if there are no migrations to push if (missingMigrations.length === 0) { @@ -58,13 +60,19 @@ export async function cmd({ config, flags }: { config: AstroConfig; flags: Argum // push the database schema if (missingMigrations.length > 0) { console.log(`Pushing ${missingMigrations.length} migrations...`); - await pushSchema({ migrations: missingMigrations, appToken, isDryRun, currentSnapshot }); + await pushSchema({ + migrations: missingMigrations, + appToken: appToken.token, + isDryRun, + currentSnapshot, + }); } // push the database seed data if (isSeedData) { console.info('Pushing data...'); - await pushData({ config, appToken, isDryRun }); + await pushData({ config, appToken: appToken.token, isDryRun }); } + await appToken.destroy(); console.info('Push complete!'); } @@ -201,7 +209,7 @@ async function runMigrateQuery({ return new Response(null, { status: 200 }); } - const url = new URL('/db/migrate/run', getRemoteDatabaseUrl()); + const url = new URL('/migrations/run', getRemoteDatabaseUrl()); return await fetch(url, { method: 'POST', @@ -219,7 +227,7 @@ async function prepareMigrateQuery({ migrations: string[]; appToken: string; }) { - const url = new URL('/db/migrate/prepare', getRemoteDatabaseUrl()); + const url = new URL('/migrations/prepare', getRemoteDatabaseUrl()); const requestBody = { migrations, experimentalVersion: 1, diff --git a/packages/db/src/core/cli/commands/shell/index.ts b/packages/db/src/core/cli/commands/shell/index.ts index 272d2bc542..0cace98c50 100644 --- a/packages/db/src/core/cli/commands/shell/index.ts +++ b/packages/db/src/core/cli/commands/shell/index.ts @@ -1,20 +1,21 @@ import type { AstroConfig } from 'astro'; import { sql } from 'drizzle-orm'; import type { Arguments } from 'yargs-parser'; -import { APP_TOKEN_ERROR } from '../../../errors.js'; -import { getAstroStudioEnv, getRemoteDatabaseUrl } from '../../../utils.js'; import { createRemoteDatabaseClient } from '../../../../runtime/db-client.js'; +import { APP_TOKEN_ERROR } from '../../../errors.js'; +import { getManagedAppToken } from '../../../tokens.js'; +import { getRemoteDatabaseUrl } from '../../../utils.js'; export async function cmd({ flags }: { config: AstroConfig; flags: Arguments }) { const query = flags.query; - const appToken = flags.token ?? getAstroStudioEnv().ASTRO_STUDIO_APP_TOKEN; + const appToken = await getManagedAppToken(flags.token); if (!appToken) { console.error(APP_TOKEN_ERROR); process.exit(1); } - - const db = createRemoteDatabaseClient(appToken, getRemoteDatabaseUrl()); + const db = createRemoteDatabaseClient(appToken.token, getRemoteDatabaseUrl()); // Temporary: create the migration table just in case it doesn't exist const result = await db.run(sql.raw(query)); + await appToken.destroy(); console.log(result); } diff --git a/packages/db/src/core/cli/index.ts b/packages/db/src/core/cli/index.ts index ca29461691..890271d4d0 100644 --- a/packages/db/src/core/cli/index.ts +++ b/packages/db/src/core/cli/index.ts @@ -12,20 +12,32 @@ export async function cli({ flags, config }: { flags: Arguments; config: AstroCo switch (command) { case 'shell': { - const { cmd: shellCommand } = await import('./commands/shell/index.js'); - return await shellCommand({ config, flags }); + const { cmd } = await import('./commands/shell/index.js'); + return await cmd({ config, flags }); } case 'sync': { - const { cmd: syncCommand } = await import('./commands/sync/index.js'); - return await syncCommand({ config, flags }); + const { cmd } = await import('./commands/sync/index.js'); + return await cmd({ config, flags }); } case 'push': { - const { cmd: pushCommand } = await import('./commands/push/index.js'); - return await pushCommand({ config, flags }); + const { cmd } = await import('./commands/push/index.js'); + return await cmd({ config, flags }); } case 'verify': { - const { cmd: verifyCommand } = await import('./commands/verify/index.js'); - return await verifyCommand({ config, flags }); + const { cmd } = await import('./commands/verify/index.js'); + return await cmd({ config, flags }); + } + case 'login': { + const { cmd } = await import('./commands/login/index.js'); + return await cmd({ config, flags }); + } + case 'logout': { + const { cmd } = await import('./commands/logout/index.js'); + return await cmd({ config, flags }); + } + case 'link': { + const { cmd } = await import('./commands/link/index.js'); + return await cmd({ config, flags }); } default: { if (command == null) { diff --git a/packages/db/src/core/tokens.ts b/packages/db/src/core/tokens.ts new file mode 100644 index 0000000000..1f33c0ef06 --- /dev/null +++ b/packages/db/src/core/tokens.ts @@ -0,0 +1,134 @@ +import { readFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { getAstroStudioEnv, getAstroStudioUrl } from './utils.js'; + +export const SESSION_LOGIN_FILE = pathToFileURL(join(homedir(), '.astro', 'session-token')); +export const PROJECT_ID_FILE = pathToFileURL(join(process.cwd(), '.astro', 'link')); + +export interface ManagedAppToken { + token: string; + renew(): Promise; + destroy(): Promise; +} + +class ManagedLocalAppToken implements ManagedAppToken { + token: string; + constructor(token: string) { + this.token = token; + } + async renew() {} + async destroy() {} +} + +class ManagedRemoteAppToken implements ManagedAppToken { + token: string; + session: string; + projectId: string; + ttl: number; + renewTimer: NodeJS.Timeout | undefined; + + static async create(sessionToken: string, projectId: string) { + const response = await fetch(new URL(`${getAstroStudioUrl()}/auth/cli/token-create`), { + method: 'POST', + headers: new Headers({ + Authorization: `Bearer ${sessionToken}`, + }), + body: JSON.stringify({ projectId }), + }); + const { token: shortLivedAppToken, ttl } = (await response.json()); + return new ManagedRemoteAppToken({ + token: shortLivedAppToken, + session: sessionToken, + projectId, + ttl, + }); + } + + constructor(options: { token: string; session: string; projectId: string; ttl: number }) { + this.token = options.token; + this.session = options.session; + this.projectId = options.projectId; + this.ttl = options.ttl; + this.renewTimer = setTimeout(() => this.renew(), (1000 * 60 * 5) / 2); + } + + private async fetch(url: string, body: unknown) { + return fetch(`${getAstroStudioUrl()}${url}`, { + method: 'POST', + headers: { + Authorization: `Bearer ${this.session}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + } + + async renew() { + clearTimeout(this.renewTimer); + delete this.renewTimer; + try { + const response = await this.fetch('/auth/cli/token-renew', { + token: this.token, + projectId: this.projectId, + }); + if (response.status === 200) { + this.renewTimer = setTimeout(() => this.renew(), (1000 * 60 * this.ttl) / 2); + } else { + throw new Error(`Unexpected response: ${response.status} ${response.statusText}`); + } + } catch (error: any) { + const retryIn = (60 * this.ttl) / 10; + console.error(`Failed to renew token. Retrying in ${retryIn} seconds.`, error?.message); + this.renewTimer = setTimeout(() => this.renew(), retryIn * 1000); + } + } + + async destroy() { + try { + const response = await this.fetch('/auth/cli/token-delete', { + token: this.token, + projectId: this.projectId, + }); + if (response.status !== 200) { + throw new Error(`Unexpected response: ${response.status} ${response.statusText}`); + } + } catch (error: any) { + console.error('Failed to delete token.', error?.message); + } + } +} + + +export async function getProjectIdFromFile() { + try { + return await readFile(PROJECT_ID_FILE, 'utf-8'); + } catch (error) { + return undefined; + } +} + +export async function getSessionIdFromFile() { + try { + return await readFile(SESSION_LOGIN_FILE, 'utf-8'); + } catch (error) { + return undefined; + } +} + +export async function getManagedAppToken(token?: string): Promise { + if (token) { + return new ManagedLocalAppToken(token); + } + const { ASTRO_STUDIO_APP_TOKEN } = getAstroStudioEnv(); + if (ASTRO_STUDIO_APP_TOKEN) { + return new ManagedLocalAppToken(ASTRO_STUDIO_APP_TOKEN); + } + const sessionToken = await getSessionIdFromFile(); + const projectId = await getProjectIdFromFile(); + if (!sessionToken || !projectId) { + return undefined; + } + return ManagedRemoteAppToken.create(sessionToken, projectId); +} diff --git a/packages/db/src/core/utils.ts b/packages/db/src/core/utils.ts index 9fe6da4fdf..e1879b680a 100644 --- a/packages/db/src/core/utils.ts +++ b/packages/db/src/core/utils.ts @@ -12,3 +12,8 @@ export function getRemoteDatabaseUrl(): string { const env = getAstroStudioEnv(); return env.ASTRO_STUDIO_REMOTE_DB_URL; } + +export function getAstroStudioUrl(): string { + const env = getAstroStudioEnv(); + return env.ASTRO_STUDIO_URL; +} diff --git a/packages/db/src/runtime/db-client.ts b/packages/db/src/runtime/db-client.ts index d323036eb7..ba2cce3b05 100644 --- a/packages/db/src/runtime/db-client.ts +++ b/packages/db/src/runtime/db-client.ts @@ -50,7 +50,7 @@ function checkIfModificationIsAllowed(collections: DBCollections, Table: SQLiteT } export function createRemoteDatabaseClient(appToken: string, remoteDbURL: string) { - const url = new URL('./db/query/', remoteDbURL); + const url = new URL('/db/query', remoteDbURL); const db = drizzleProxy(async (sql, parameters, method) => { const requestBody: InStatement = { sql, args: parameters }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 151902ba9c..b3f26e7249 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3805,6 +3805,12 @@ importers: nanoid: specifier: ^5.0.1 version: 5.0.1 + open: + specifier: ^10.0.3 + version: 10.0.3 + ora: + specifier: ^7.0.1 + version: 7.0.1 prompts: specifier: ^2.4.2 version: 2.4.2 @@ -9108,6 +9114,13 @@ packages: ieee754: 1.2.1 dev: false + /bundle-name@4.1.0: + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + engines: {node: '>=18'} + dependencies: + run-applescript: 7.0.0 + dev: false + /bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} @@ -9799,6 +9812,19 @@ packages: engines: {node: '>=0.10.0'} dev: false + /default-browser-id@5.0.0: + resolution: {integrity: sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA==} + engines: {node: '>=18'} + dev: false + + /default-browser@5.2.1: + resolution: {integrity: sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg==} + engines: {node: '>=18'} + dependencies: + bundle-name: 4.1.0 + default-browser-id: 5.0.0 + dev: false + /defaults@1.0.4: resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} dependencies: @@ -9814,6 +9840,11 @@ packages: has-property-descriptors: 1.0.1 dev: true + /define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + dev: false + /define-properties@1.2.1: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} @@ -13401,6 +13432,16 @@ packages: resolution: {integrity: sha512-6mp/gpLQz/ZwrGLz+i7tSJ3eWNLE1KxLXHO+b6xxRyZ1Alp4TgTcvHiQ89rC2IkvsU3/IRhpIJuxl7rRCwUzLA==} dev: false + /open@10.0.3: + resolution: {integrity: sha512-dtbI5oW7987hwC9qjJTyABldTaa19SuyJse1QboWv3b0qCcrrLNVDqBx1XgELAjh9QTVQaP/C5b1nhQebd1H2A==} + engines: {node: '>=18'} + dependencies: + default-browser: 5.2.1 + define-lazy-prop: 3.0.0 + is-inside-container: 1.0.0 + is-wsl: 3.1.0 + dev: false + /optionator@0.9.3: resolution: {integrity: sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==} engines: {node: '>= 0.8.0'} @@ -14815,6 +14856,11 @@ packages: resolution: {integrity: sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==} dev: true + /run-applescript@7.0.0: + resolution: {integrity: sha512-9by4Ij99JUr/MCFBUkDKLWK3G9HVXmabKz9U5MlIAIuvuzkiOicRYs8XJLxX+xahD+mLiiCYDqF9dKAgtzKP1A==} + engines: {node: '>=18'} + dev: false + /run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} dependencies: