Skip to content

Commit 3e0970a

Browse files
authored
feat: improve supabase integrations ux (#29)
1 parent b306dc1 commit 3e0970a

10 files changed

+428
-129
lines changed

packages/core/installMachine/installSteps/payload/install.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export const preparePayload = async () => {
3131

3232
// Install Payload
3333
execSync(`echo y | npx create-payload-app@beta --db postgres --db-connection-string ${process.env.DB_URL}`, {
34-
stdio: 'inherit',
34+
stdio: ['inherit', 'ignore', 'inherit'],
3535
});
3636

3737
// Payload doesn't work with Turbopack yet
Original file line numberDiff line numberDiff line change
@@ -1,72 +1,146 @@
11
import { exec, execSync } from 'child_process';
2-
import inquirer from 'inquirer';
32
import { promisify } from 'util';
43
import chalk from 'chalk';
5-
import { continueOnAnyKeypress } from '../../../utils/continueOnKeypress';
4+
import boxen from 'boxen';
65
import { getSupabaseKeys, parseProjectsList } from './utils';
7-
import { logWithColoredPrefix } from '../../../utils/logWithColoredPrefix';
6+
import { logger } from '../../../utils/logWithColoredPrefix';
7+
import { getVercelTokenFromAuthFile } from '../../../utils/getVercelTokenFromAuthFile';
8+
import { getProjectIdFromVercelConfig } from '../../../utils/getProjectIdFromVercelConfig';
89

910
const execAsync = promisify(exec);
11+
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
1012

1113
export const connectSupabaseProject = async (projectName: string, currentDir: string) => {
1214
try {
13-
logWithColoredPrefix('supabase', 'Getting information about newly created project...');
14-
const { stdout: projectsList } = await execAsync('npx supabase projects list');
15-
const projects = parseProjectsList(projectsList);
16-
const newProject = projects.find((project) => project.name === projectName);
17-
18-
if (!newProject || !newProject.refId) {
19-
throw new Error(
20-
`Could not find Supabase project "${projectName}". Please ensure the project exists and you have the correct permissions.`,
21-
);
22-
}
15+
// Get project information
16+
const newProject = await logger.withSpinner('supabase', 'Getting project information...', async (spinner) => {
17+
const { stdout: projectsList } = await execAsync('npx supabase projects list');
18+
const projects = parseProjectsList(projectsList);
19+
const project = projects.find((p) => p.name === projectName);
20+
21+
if (!project || !project.refId) {
22+
spinner.fail('Project not found');
23+
throw new Error(
24+
`Could not find Supabase project "${projectName}". Please ensure the project exists and you have the correct permissions.`,
25+
);
26+
}
27+
28+
spinner.succeed('Project found!');
29+
return project;
30+
});
31+
32+
// Get API keys
33+
const { anonKey, serviceRoleKey } = await logger.withSpinner(
34+
'supabase',
35+
'Getting project API keys...',
36+
async (spinner) => {
37+
const { stdout: projectAPIKeys } = await execAsync(
38+
`npx supabase projects api-keys --project-ref ${newProject.refId}`,
39+
);
40+
41+
const keys = getSupabaseKeys(projectAPIKeys);
42+
if (!keys.anonKey || !keys.serviceRoleKey) {
43+
spinner.fail('Failed to retrieve API keys');
44+
throw new Error('Failed to retrieve Supabase API keys. Please check your project configuration.');
45+
}
2346

24-
logWithColoredPrefix('supabase', 'Getting project keys...');
25-
const { stdout: projectAPIKeys } = await execAsync(
26-
`npx supabase projects api-keys --project-ref ${newProject.refId}`,
47+
spinner.succeed('API keys retrieved!');
48+
return keys;
49+
},
2750
);
2851

29-
const { anonKey, serviceRoleKey } = getSupabaseKeys(projectAPIKeys);
52+
// Link project
53+
logger.log('supabase', 'Linking project...');
54+
execSync(`npx supabase link --project-ref ${newProject.refId}`, {
55+
stdio: 'inherit',
56+
});
3057

31-
if (!anonKey || !serviceRoleKey) {
32-
throw new Error('Failed to retrieve Supabase API keys. Please check your project configuration.');
33-
}
58+
// Display integration instructions
59+
console.log(
60+
boxen(
61+
chalk.bold('Supabase Integration Setup\n\n') +
62+
chalk.hex('#3ABC82')('1.') +
63+
' You will be redirected to your project dashboard\n' +
64+
chalk.hex('#3ABC82')('2.') +
65+
' Connect Vercel: "Add new project connection"\n' +
66+
chalk.hex('#3ABC82')('3.') +
67+
' (Optional) Connect GitHub: "Add new project connection"\n\n' +
68+
chalk.dim('Tip: Keep this terminal open to track the integration status'),
69+
{
70+
padding: 1,
71+
margin: 1,
72+
borderStyle: 'round',
73+
borderColor: '#3ABC82',
74+
},
75+
),
76+
);
3477

35-
logWithColoredPrefix('supabase', 'Linking project...');
36-
execSync(`npx supabase link --project-ref ${newProject.refId}`, { stdio: 'inherit' });
78+
// Countdown and open dashboard
79+
const spinner = logger.createSpinner('supabase', 'Preparing to open dashboard');
80+
spinner.start();
3781

38-
logWithColoredPrefix('supabase', [
39-
chalk.bold('=== Instructions for integration with GitHub and Vercel ==='),
40-
'\n1. You will be redirected to your project dashboard',
41-
'\n2. Find the "GitHub" section and click "Connect".',
42-
'\n - Follow the prompts to connect with your GitHub repository.',
43-
'\n3. Then, find the "Vercel" section and click "Connect".',
44-
'\n - Follow the prompts to connect with your Vercel project.',
45-
chalk.italic('\nNOTE: These steps require manual configuration in the Supabase interface.'),
46-
]);
82+
for (let i = 3; i > 0; i--) {
83+
spinner.text = `Opening dashboard in ${chalk.hex('#3ABC82')(i)}...`;
84+
await delay(1000);
85+
}
4786

48-
await continueOnAnyKeypress('When you are ready to be redirected to the Supabase page press any key');
87+
spinner.text = 'Opening dashboard in your browser...';
4988
await execAsync(`open https://supabase.com/dashboard/project/${newProject.refId}/settings/integrations`);
89+
spinner.succeed('Dashboard opened.');
5090

51-
const { isIntegrationReady } = await inquirer.prompt([
52-
{
53-
type: 'confirm',
54-
name: 'isIntegrationReady',
55-
message: 'Have you completed the GitHub and Vercel integration setup?',
56-
default: false,
57-
},
58-
]);
91+
// Check Vercel integration
92+
await logger.withSpinner('vercel', 'Checking integration...', async (spinner) => {
93+
const token = await getVercelTokenFromAuthFile();
94+
const vercelProjectId = await getProjectIdFromVercelConfig();
5995

60-
if (!isIntegrationReady) {
61-
logWithColoredPrefix(
62-
'supabase',
63-
`You can access your project dashboard at: https://supabase.com/dashboard/project/${newProject.refId}/settings/integrations`,
64-
),
65-
process.exit(1);
66-
}
96+
let attempts = 0;
97+
const maxAttempts = 30;
98+
const interval = 5000;
99+
100+
while (attempts < maxAttempts) {
101+
try {
102+
const response = await fetch(`https://api.vercel.com/v9/projects/${vercelProjectId}/env`, {
103+
headers: { Authorization: `Bearer ${token}` },
104+
method: 'get',
105+
});
106+
107+
const envVarsSet = await response.json();
108+
const supabaseUrl = envVarsSet.envs.find((env: { key: string }) => env.key === 'SUPABASE_URL')?.value;
109+
110+
if (supabaseUrl) {
111+
spinner.succeed('Integration complete!');
112+
return true;
113+
}
114+
115+
attempts++;
116+
spinner.text = `Checking integration status ${chalk.hex('#3ABC82')(`[${attempts}/${maxAttempts}]`)}`;
117+
await delay(interval);
118+
} catch (error) {
119+
spinner.fail('Failed to check Vercel integration status');
120+
throw error;
121+
}
122+
}
123+
124+
// Timeout reached
125+
spinner.warn('Integration check timed out');
126+
console.log(
127+
boxen(
128+
chalk.yellow('Integration Status Unknown\n\n') +
129+
'You can manually verify the integration at:\n' +
130+
chalk.hex('#3ABC82')(`https://supabase.com/dashboard/project/${newProject.refId}/settings/integrations`),
131+
{
132+
padding: 1,
133+
margin: 1,
134+
borderStyle: 'round',
135+
borderColor: 'yellow',
136+
},
137+
),
138+
);
139+
140+
return false;
141+
});
67142
} catch (error) {
68-
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred';
69-
console.error('Error connecting Supabase project:', errorMessage);
143+
logger.log('error', error instanceof Error ? error.message : 'An unknown error occurred');
70144
throw error;
71145
}
72146
};

packages/core/installMachine/installSteps/vercel/updateProjectSettings.ts

+3-32
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,11 @@
1-
import fs from 'fs/promises';
2-
import path from 'path';
3-
import { getGlobalPathConfig } from './utils/getGlobalPathConfig';
41
import { logWithColoredPrefix } from '../../../utils/logWithColoredPrefix';
5-
6-
const getTokenFromAuthFile = async (filePath: string): Promise<string | null> => {
7-
try {
8-
const data = await fs.readFile(filePath, 'utf-8');
9-
const jsonData = JSON.parse(data);
10-
return jsonData.token || null;
11-
} catch (error) {
12-
console.error('Failed to read or parse auth.json:', `\n${error}`);
13-
process.exit(1);
14-
}
15-
};
16-
17-
const getProjectIdFromVercelConfig = async (): Promise<string | null> => {
18-
const data = await fs.readFile('.vercel/project.json', 'utf-8');
19-
try {
20-
const jsonData = JSON.parse(data);
21-
return jsonData.projectId;
22-
} catch (error) {
23-
console.error('Failed to read or parse vercel.json:', `\n${error}`);
24-
process.exit(1);
25-
}
26-
};
2+
import { getProjectIdFromVercelConfig } from '../../../utils/getProjectIdFromVercelConfig';
3+
import { getVercelTokenFromAuthFile } from '../../../utils/getVercelTokenFromAuthFile';
274

285
export const updateVercelProjectSettings = async () => {
296
logWithColoredPrefix('vercel', 'Changing project settings...');
30-
const globalPath = await getGlobalPathConfig();
31-
if (!globalPath) {
32-
console.error('Global path not found. Cannot update project properties.');
33-
process.exit(1);
34-
}
35-
const filePath = path.join(globalPath, 'auth.json');
367

37-
const token = await getTokenFromAuthFile(filePath);
8+
const token = await getVercelTokenFromAuthFile();
389
if (!token) {
3910
console.error('Token not found. Cannot update project properties.');
4011
process.exit(1);

packages/core/package.json

+4-2
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,13 @@
88
"dev": "tsc -w"
99
},
1010
"dependencies": {
11+
"boxen": "^8.0.1",
1112
"chalk": "^5.3.0",
1213
"fs-extra": "^11.2.0",
1314
"gradient-string": "^3.0.0",
14-
"xstate": "^5.18.2",
15-
"inquirer": "^10.2.2"
15+
"inquirer": "^10.2.2",
16+
"ora": "^8.1.1",
17+
"xstate": "^5.18.2"
1618
},
1719
"devDependencies": {
1820
"@types/fs-extra": "^11.0.4",

packages/core/utils/continueOnKeypress.ts

-36
This file was deleted.
+53
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { homedir } from 'os';
2+
import fs from 'fs';
3+
import path from 'path';
4+
5+
const getXDGPaths = (appName: string) => {
6+
const homeDir = homedir();
7+
8+
if (process.platform === 'win32') {
9+
// Windows paths, typically within %AppData%
10+
return {
11+
dataDirs: [path.join(process.env.APPDATA || path.join(homeDir, 'AppData', 'Roaming'), appName)],
12+
configDirs: [path.join(process.env.APPDATA || path.join(homeDir, 'AppData', 'Roaming'), appName)],
13+
cacheDir: path.join(process.env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local'), appName, 'Cache'),
14+
};
15+
} else if (process.platform === 'darwin') {
16+
// macOS paths, typically in ~/Library/Application Support
17+
return {
18+
dataDirs: [path.join(homeDir, 'Library', 'Application Support', appName)],
19+
configDirs: [path.join(homeDir, 'Library', 'Application Support', appName)],
20+
cacheDir: path.join(homeDir, 'Library', 'Caches', appName),
21+
};
22+
} else {
23+
// Linux/Unix paths, following the XDG Base Directory Specification
24+
return {
25+
dataDirs: [process.env.XDG_DATA_HOME || path.join(homeDir, '.local', 'share', appName)],
26+
configDirs: [process.env.XDG_CONFIG_HOME || path.join(homeDir, '.config', appName)],
27+
cacheDir: process.env.XDG_CACHE_HOME || path.join(homeDir, '.cache', appName),
28+
};
29+
}
30+
};
31+
32+
// Returns whether a directory exists
33+
const isDirectory = (path: string): boolean => {
34+
try {
35+
return fs.lstatSync(path).isDirectory();
36+
} catch (_) {
37+
// We don't care which kind of error occured, it isn't a directory anyway.
38+
return false;
39+
}
40+
};
41+
42+
// Returns in which directory the config should be present
43+
export const getGlobalPathConfig = async (appName: string): Promise<string> => {
44+
const vercelDirectories = getXDGPaths(appName).dataDirs;
45+
46+
const possibleConfigPaths = [
47+
...vercelDirectories, // latest vercel directory
48+
path.join(homedir(), '.now'), // legacy config in user's home directory
49+
...getXDGPaths('now').dataDirs, // legacy XDG directory
50+
];
51+
52+
return possibleConfigPaths.find((configPath) => isDirectory(configPath)) || vercelDirectories[0];
53+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import fs from 'fs/promises';
2+
3+
export const getProjectIdFromVercelConfig = async (): Promise<string | null> => {
4+
const data = await fs.readFile('.vercel/project.json', 'utf-8');
5+
try {
6+
const jsonData = JSON.parse(data);
7+
return jsonData.projectId;
8+
} catch (error) {
9+
console.error('Failed to read or parse vercel.json:', `\n${error}`);
10+
process.exit(1);
11+
}
12+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import fs from 'fs/promises';
2+
import path from 'path';
3+
import { getGlobalPathConfig } from './getGlobalPathConfig';
4+
5+
export const getVercelTokenFromAuthFile = async (): Promise<string | null> => {
6+
const globalPath = await getGlobalPathConfig('com.vercel.cli');
7+
if (!globalPath) {
8+
console.error('Global path not found. Cannot update project properties.');
9+
process.exit(1);
10+
}
11+
12+
const filePath = path.join(globalPath, 'auth.json');
13+
14+
try {
15+
const data = await fs.readFile(filePath, 'utf-8');
16+
const jsonData = JSON.parse(data);
17+
return jsonData.token || null;
18+
} catch (error) {
19+
console.error('Failed to read or parse auth.json:', `\n${error}`);
20+
process.exit(1);
21+
}
22+
};

0 commit comments

Comments
 (0)