move supabase start to its own commands

This commit is contained in:
Nate Kelley 2025-09-24 16:33:26 -06:00
parent 052901012e
commit 299ed5d697
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
21 changed files with 141 additions and 133 deletions

View File

@ -8,18 +8,18 @@
"inputs": ["server/**/*", "libs/**/*", "Cargo.toml", "Cargo.lock"] "inputs": ["server/**/*", "libs/**/*", "Cargo.toml", "Cargo.lock"]
}, },
"start": { "start": {
"dependsOn": ["@buster/database#start", "build"], "dependsOn": ["@buster-app/supabase#start", "build"],
"cache": true, "cache": true,
"persistent": true "persistent": true
}, },
"dev": { "dev": {
"dependsOn": ["@buster/database#start"], "dependsOn": ["@buster-app/supabase#start"],
"cache": false, "cache": false,
"persistent": true, "persistent": true,
"outputs": [] "outputs": []
}, },
"dev:fast": { "dev:fast": {
"dependsOn": ["@buster/database#start"], "dependsOn": ["@buster-app/supabase#start"],
"cache": false, "cache": false,
"persistent": true, "persistent": true,
"outputs": [] "outputs": []

View File

@ -3,19 +3,19 @@
"extends": ["//"], "extends": ["//"],
"tasks": { "tasks": {
"start": { "start": {
"dependsOn": ["@buster/database#start"], "dependsOn": ["@buster-app/supabase#start"],
"cache": true, "cache": true,
"persistent": false, "persistent": false,
"outputs": [] "outputs": []
}, },
"dev": { "dev": {
"dependsOn": ["@buster/database#start"], "dependsOn": ["@buster-app/supabase#start"],
"cache": false, "cache": false,
"persistent": true, "persistent": true,
"outputs": [] "outputs": []
}, },
"dev:fast": { "dev:fast": {
"dependsOn": ["@buster/database#start"], "dependsOn": ["@buster-app/supabase#start"],
"cache": false, "cache": false,
"persistent": true, "persistent": true,
"outputs": [] "outputs": []

View File

@ -7,14 +7,14 @@
"outputs": ["dist/**"] "outputs": ["dist/**"]
}, },
"start": { "start": {
"dependsOn": ["@buster/database#start", "build"], "dependsOn": ["@buster-app/supabase#start", "build"],
"cache": true, "cache": true,
"persistent": true "persistent": true
}, },
"dev": { "dev": {
"cache": false, "cache": false,
"persistent": true, "persistent": true,
"dependsOn": ["@buster/database#start", "^build"], "dependsOn": ["@buster-app/supabase#start", "^build"],
"with": [ "with": [
"@buster/ai#dev", "@buster/ai#dev",
"@buster/server-shared#dev", "@buster/server-shared#dev",
@ -28,7 +28,7 @@
"dev:fast": { "dev:fast": {
"cache": false, "cache": false,
"persistent": true, "persistent": true,
"dependsOn": ["@buster/database#start", "^build"], "dependsOn": ["@buster-app/supabase#start", "^build"],
"with": [ "with": [
"@buster/ai#dev:fast", "@buster/ai#dev:fast",
"@buster/server-shared#dev:fast", "@buster/server-shared#dev:fast",

View File

@ -0,0 +1 @@
main

View File

@ -3,3 +3,4 @@ volumes/storage
.env .env
test.http test.http
docker-compose.override.yml docker-compose.override.yml
.temp

View File

@ -2,12 +2,12 @@
"name": "@buster-app/supabase", "name": "@buster-app/supabase",
"version": "0.0.1", "version": "0.0.1",
"private": false, "private": false,
"dependencies": { "dependencies": {},
"@buster/database": "workspace:*"
},
"scripts": { "scripts": {
"lint": "biome check . --write", "lint": "biome check . --write",
"lint:fix": "biome check . --write", "lint:fix": "biome check . --write",
"start": "echo 'Please run this command through Turbo from the root: turbo start'" "start": "supabase start",
"stop": "supabase stop",
"reset": "supabase stop && supabase start && supabase db reset"
} }
} }

View File

@ -3,7 +3,19 @@
"extends": ["//"], "extends": ["//"],
"tasks": { "tasks": {
"start": { "start": {
"dependsOn": ["@buster/database#start"], "dependsOn": [],
"cache": false,
"persistent": false,
"outputs": []
},
"stop": {
"dependsOn": [],
"cache": false,
"persistent": false,
"outputs": []
},
"reset": {
"dependsOn": ["stop"],
"cache": false, "cache": false,
"persistent": false, "persistent": false,
"outputs": [] "outputs": []

View File

@ -15,8 +15,8 @@
"dev": { "dev": {
"cache": false, "cache": false,
"persistent": true, "persistent": true,
"dependsOn": ["^build"], "dependsOn": ["^build","@buster-app/supabase#start"],
"with": ["@buster-app/supabase#start"] "with": ["@buster/database#dev"]
}, },
"dev:fast": { "dev:fast": {
"cache": false, "cache": false,

View File

@ -1,3 +1,4 @@
import { useMutation } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router'; import { Link } from '@tanstack/react-router';
import type React from 'react'; import type React from 'react';
import { useState } from 'react'; import { useState } from 'react';
@ -5,32 +6,26 @@ import { Button } from '@/components/ui/buttons';
import { SuccessCard } from '@/components/ui/card/SuccessCard'; import { SuccessCard } from '@/components/ui/card/SuccessCard';
import { Input } from '@/components/ui/inputs'; import { Input } from '@/components/ui/inputs';
import { Text, Title } from '@/components/ui/typography'; import { Text, Title } from '@/components/ui/typography';
import { useBusterNotifications } from '@/context/BusterNotifications';
import { resetPasswordEmailSend } from '@/integrations/supabase/resetPassword'; import { resetPasswordEmailSend } from '@/integrations/supabase/resetPassword';
import { cn } from '@/lib/classMerge'; import { cn } from '@/lib/classMerge';
import { isValidEmail } from '@/lib/email'; import { isValidEmail } from '@/lib/email';
import { timeout } from '@/lib/timeout';
export const ResetEmailForm: React.FC<{ export const ResetEmailForm: React.FC<{
queryEmail: string; queryEmail: string;
}> = ({ queryEmail }) => { }> = ({ queryEmail }) => {
const [loading, setLoading] = useState(false);
const [email, setEmail] = useState(queryEmail); const [email, setEmail] = useState(queryEmail);
const [emailSent, setEmailSent] = useState(false); const [emailSent, setEmailSent] = useState(false);
const { openErrorNotification } = useBusterNotifications(); const { mutateAsync: resetPasswordEmailSendMutation, isPending: loading } = useMutation({
mutationFn: resetPasswordEmailSend,
});
const disabled = !email || !isValidEmail(email); const disabled = !email || !isValidEmail(email);
const handleResetPassword = async () => { const handleResetPassword = async () => {
if (disabled) return; if (disabled) return;
setLoading(true); const [res] = await Promise.all([resetPasswordEmailSendMutation({ data: { email } })]);
const [res] = await Promise.all([resetPasswordEmailSend({ data: { email } }), timeout(450)]);
if (res?.error) { setEmailSent(true);
openErrorNotification(res.error);
} else {
setEmailSent(true);
}
setLoading(false);
}; };
if (emailSent) { if (emailSent) {

View File

@ -3,6 +3,7 @@ import type { User } from '@supabase/supabase-js';
import { useRouter } from '@tanstack/react-router'; import { useRouter } from '@tanstack/react-router';
import type React from 'react'; import type React from 'react';
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { useGetMyUserInfo } from '@/api/buster_rest/users';
import { Button } from '@/components/ui/buttons'; import { Button } from '@/components/ui/buttons';
import { SuccessCard } from '@/components/ui/card/SuccessCard'; import { SuccessCard } from '@/components/ui/card/SuccessCard';
import { Input } from '@/components/ui/inputs'; import { Input } from '@/components/ui/inputs';
@ -13,8 +14,8 @@ import { PolicyCheck } from './PolicyCheck';
export const ResetPasswordForm: React.FC<{ export const ResetPasswordForm: React.FC<{
supabaseUser: Pick<User, 'email'>; supabaseUser: Pick<User, 'email'>;
busterUser: UserResponse; }> = ({ supabaseUser }) => {
}> = ({ supabaseUser, busterUser }) => { const { data: busterUser } = useGetMyUserInfo();
const router = useRouter(); const router = useRouter();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [resetSuccess, setResetSuccess] = useState(false); const [resetSuccess, setResetSuccess] = useState(false);

View File

@ -2,6 +2,7 @@ import { createServerFn } from '@tanstack/react-start';
import { z } from 'zod'; import { z } from 'zod';
import { env } from '@/env'; import { env } from '@/env';
import { ServerRoute as AuthCallbackRoute } from '../../routes/auth.callback'; import { ServerRoute as AuthCallbackRoute } from '../../routes/auth.callback';
import { Route as AuthResetPasswordRoute } from '../../routes/auth.reset-password';
import { getSupabaseServerClient } from './server'; import { getSupabaseServerClient } from './server';
export const resetPasswordEmailSend = createServerFn({ method: 'POST' }) export const resetPasswordEmailSend = createServerFn({ method: 'POST' })
@ -10,14 +11,17 @@ export const resetPasswordEmailSend = createServerFn({ method: 'POST' })
const supabase = await getSupabaseServerClient(); const supabase = await getSupabaseServerClient();
const url = env.VITE_PUBLIC_URL; const url = env.VITE_PUBLIC_URL;
const authURLFull = `${url}${AuthCallbackRoute.to}`; const authURLFull = `${url}${AuthResetPasswordRoute.to}`;
console.log('email', email);
console.log('authURLFull', authURLFull);
const { error } = await supabase.auth.resetPasswordForEmail(email, { const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: authURLFull, redirectTo: authURLFull,
}); });
if (error) { if (error) {
return { error: error.message }; throw new Error(error.message);
} }
return; return;
@ -28,6 +32,12 @@ export const resetPassword = createServerFn({ method: 'POST' })
.handler(async ({ data: { password } }) => { .handler(async ({ data: { password } }) => {
const supabase = await getSupabaseServerClient(); const supabase = await getSupabaseServerClient();
const { data: user } = await supabase.auth.getUser();
if (!user?.user) {
throw new Error('User not found');
}
const { error } = await supabase.auth.updateUser({ password }); const { error } = await supabase.auth.updateUser({ password });
if (error) { if (error) {

View File

@ -127,6 +127,7 @@ import { Route as AppAppAssetChatsChatIdReportsReportIdMetricsMetricIdContentCha
import { Route as AppAppAssetChatsChatIdDashboardsDashboardIdMetricsMetricIdContentSqlRouteImport } from './routes/app/_app/_asset/chats.$chatId/dashboards.$dashboardId/metrics.$metricId/_content/sql' import { Route as AppAppAssetChatsChatIdDashboardsDashboardIdMetricsMetricIdContentSqlRouteImport } from './routes/app/_app/_asset/chats.$chatId/dashboards.$dashboardId/metrics.$metricId/_content/sql'
import { Route as AppAppAssetChatsChatIdDashboardsDashboardIdMetricsMetricIdContentResultsRouteImport } from './routes/app/_app/_asset/chats.$chatId/dashboards.$dashboardId/metrics.$metricId/_content/results' import { Route as AppAppAssetChatsChatIdDashboardsDashboardIdMetricsMetricIdContentResultsRouteImport } from './routes/app/_app/_asset/chats.$chatId/dashboards.$dashboardId/metrics.$metricId/_content/results'
import { Route as AppAppAssetChatsChatIdDashboardsDashboardIdMetricsMetricIdContentChartRouteImport } from './routes/app/_app/_asset/chats.$chatId/dashboards.$dashboardId/metrics.$metricId/_content/chart' import { Route as AppAppAssetChatsChatIdDashboardsDashboardIdMetricsMetricIdContentChartRouteImport } from './routes/app/_app/_asset/chats.$chatId/dashboards.$dashboardId/metrics.$metricId/_content/chart'
import { ServerRoute as AuthConfirmServerRouteImport } from './routes/auth.confirm'
import { ServerRoute as AuthCallbackServerRouteImport } from './routes/auth.callback' import { ServerRoute as AuthCallbackServerRouteImport } from './routes/auth.callback'
const AppAppAssetReportsReportIdRouteImport = createFileRoute( const AppAppAssetReportsReportIdRouteImport = createFileRoute(
@ -939,6 +940,11 @@ const AppAppAssetChatsChatIdDashboardsDashboardIdMetricsMetricIdContentChartRout
AppAppAssetChatsChatIdDashboardsDashboardIdMetricsMetricIdContentRoute, AppAppAssetChatsChatIdDashboardsDashboardIdMetricsMetricIdContentRoute,
} as any, } as any,
) )
const AuthConfirmServerRoute = AuthConfirmServerRouteImport.update({
id: '/auth/confirm',
path: '/auth/confirm',
getParentRoute: () => rootServerRouteImport,
} as any)
const AuthCallbackServerRoute = AuthCallbackServerRouteImport.update({ const AuthCallbackServerRoute = AuthCallbackServerRouteImport.update({
id: '/auth/callback', id: '/auth/callback',
path: '/auth/callback', path: '/auth/callback',
@ -1621,24 +1627,28 @@ export interface RootRouteChildren {
} }
export interface FileServerRoutesByFullPath { export interface FileServerRoutesByFullPath {
'/auth/callback': typeof AuthCallbackServerRoute '/auth/callback': typeof AuthCallbackServerRoute
'/auth/confirm': typeof AuthConfirmServerRoute
} }
export interface FileServerRoutesByTo { export interface FileServerRoutesByTo {
'/auth/callback': typeof AuthCallbackServerRoute '/auth/callback': typeof AuthCallbackServerRoute
'/auth/confirm': typeof AuthConfirmServerRoute
} }
export interface FileServerRoutesById { export interface FileServerRoutesById {
__root__: typeof rootServerRouteImport __root__: typeof rootServerRouteImport
'/auth/callback': typeof AuthCallbackServerRoute '/auth/callback': typeof AuthCallbackServerRoute
'/auth/confirm': typeof AuthConfirmServerRoute
} }
export interface FileServerRouteTypes { export interface FileServerRouteTypes {
fileServerRoutesByFullPath: FileServerRoutesByFullPath fileServerRoutesByFullPath: FileServerRoutesByFullPath
fullPaths: '/auth/callback' fullPaths: '/auth/callback' | '/auth/confirm'
fileServerRoutesByTo: FileServerRoutesByTo fileServerRoutesByTo: FileServerRoutesByTo
to: '/auth/callback' to: '/auth/callback' | '/auth/confirm'
id: '__root__' | '/auth/callback' id: '__root__' | '/auth/callback' | '/auth/confirm'
fileServerRoutesById: FileServerRoutesById fileServerRoutesById: FileServerRoutesById
} }
export interface RootServerRouteChildren { export interface RootServerRouteChildren {
AuthCallbackServerRoute: typeof AuthCallbackServerRoute AuthCallbackServerRoute: typeof AuthCallbackServerRoute
AuthConfirmServerRoute: typeof AuthConfirmServerRoute
} }
declare module '@tanstack/react-router' { declare module '@tanstack/react-router' {
@ -2522,6 +2532,13 @@ declare module '@tanstack/react-router' {
} }
declare module '@tanstack/react-start/server' { declare module '@tanstack/react-start/server' {
interface ServerFileRoutesByPath { interface ServerFileRoutesByPath {
'/auth/confirm': {
id: '/auth/confirm'
path: '/auth/confirm'
fullPath: '/auth/confirm'
preLoaderRoute: typeof AuthConfirmServerRouteImport
parentRoute: typeof rootServerRouteImport
}
'/auth/callback': { '/auth/callback': {
id: '/auth/callback' id: '/auth/callback'
path: '/auth/callback' path: '/auth/callback'
@ -3309,6 +3326,7 @@ export const routeTree = rootRouteImport
._addFileTypes<FileRouteTypes>() ._addFileTypes<FileRouteTypes>()
const rootServerRouteChildren: RootServerRouteChildren = { const rootServerRouteChildren: RootServerRouteChildren = {
AuthCallbackServerRoute: AuthCallbackServerRoute, AuthCallbackServerRoute: AuthCallbackServerRoute,
AuthConfirmServerRoute: AuthConfirmServerRoute,
} }
export const serverRouteTree = rootServerRouteImport export const serverRouteTree = rootServerRouteImport
._addFileChildren(rootServerRouteChildren) ._addFileChildren(rootServerRouteChildren)

View File

@ -0,0 +1,54 @@
import type { EmailOtpType } from '@supabase/supabase-js';
import { redirect } from '@tanstack/react-router';
import { createServerFileRoute } from '@tanstack/react-start/server';
import { z } from 'zod';
import { getSupabaseServerClient } from '@/integrations/supabase/server';
import { Route as AuthResetPasswordRoute } from './auth.reset-password';
const searchParamsSchema = z.object({
code: z.string().optional(),
token_hash: z.string().optional(),
next: z.string().optional(),
type: z.string().optional(),
});
export const ServerRoute = createServerFileRoute('/auth/confirm').methods({
GET: async ({ request }) => {
const url = new URL(request.url);
const { data: searchParams } = searchParamsSchema.safeParse({
code: url.searchParams.get('code') || undefined,
token_hash: url.searchParams.get('token_hash') || undefined,
type: url.searchParams.get('type') || undefined,
next: url.searchParams.get('next') || undefined,
});
if (!searchParams) {
return new Response('Invalid search params', { status: 400 });
}
const supabase = await getSupabaseServerClient();
const { token_hash, type } = searchParams;
if (!token_hash || !type) {
return new Response('Invalid search params', { status: 400 });
}
const { data, error } = await supabase.auth.verifyOtp({
token_hash: token_hash,
type: type as EmailOtpType,
});
if (!error) {
throw redirect({
to: AuthResetPasswordRoute.to,
search: {
email: data.user?.email || '',
},
});
}
console.error('Error verifying OTP', error);
return new Response('Error verifying OTP', { status: 400 });
},
});

View File

@ -1,17 +1,10 @@
import { createFileRoute } from '@tanstack/react-router'; import { createFileRoute } from '@tanstack/react-router';
import { z } from 'zod'; import { z } from 'zod';
import { prefetchGetMyUserInfo } from '@/api/buster_rest/users';
import { ResetEmailForm } from '@/components/features/auth/ResetEmailForm'; import { ResetEmailForm } from '@/components/features/auth/ResetEmailForm';
import { ResetPasswordForm } from '@/components/features/auth/ResetPasswordForm'; import { ResetPasswordForm } from '@/components/features/auth/ResetPasswordForm';
import { useGetSupabaseUser } from '@/context/Supabase'; import { useGetSupabaseUser } from '@/context/Supabase';
export const Route = createFileRoute('/auth/reset-password')({ export const Route = createFileRoute('/auth/reset-password')({
loader: async ({ context }) => {
const user = await prefetchGetMyUserInfo(context.queryClient);
return {
user,
};
},
head: () => ({ head: () => ({
meta: [ meta: [
{ title: 'Reset Password' }, { title: 'Reset Password' },
@ -22,26 +15,17 @@ export const Route = createFileRoute('/auth/reset-password')({
}), }),
component: RouteComponent, component: RouteComponent,
validateSearch: z.object({ validateSearch: z.object({
email: z.string(), email: z.string().optional(),
}), }),
}); });
function RouteComponent() { function RouteComponent() {
const { user } = Route.useLoaderData();
const { email } = Route.useSearch(); const { email } = Route.useSearch();
const supabaseUser = useGetSupabaseUser(); const supabaseUser = useGetSupabaseUser();
if (email) { if (email || supabaseUser?.is_anonymous || !supabaseUser) {
return <ResetEmailForm queryEmail={email} />; return <ResetEmailForm queryEmail={email || ''} />;
} }
if (!supabaseUser || !user) { return <ResetPasswordForm supabaseUser={supabaseUser} />;
return (
<div className="flex h-full flex-col items-center justify-center p-10">
We were unable to find your account
</div>
);
}
return <ResetPasswordForm supabaseUser={supabaseUser} busterUser={user} />;
} }

View File

@ -31,15 +31,12 @@
"build": "tsc", "build": "tsc",
"build:dry-run": "tsc", "build:dry-run": "tsc",
"db:init": "echo 'Initializing database...'", "db:init": "echo 'Initializing database...'",
"start": "supabase start",
"db:reset": "supabase stop && supabase start && supabase db reset",
"db:seed": "tsx scripts/seed.ts", "db:seed": "tsx scripts/seed.ts",
"db:dump": "tsx scripts/dump.ts", "db:dump": "tsx scripts/dump.ts",
"db:migrate": "drizzle-kit migrate", "db:migrate": "drizzle-kit migrate",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:push": "drizzle-kit push", "db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio", "db:studio": "drizzle-kit studio",
"db:stop": "supabase stop",
"dev": "tsc --watch", "dev": "tsc --watch",
"lint": "biome check --write", "lint": "biome check --write",
"test": "vitest run", "test": "vitest run",
@ -53,6 +50,7 @@
"@buster/env-utils": "workspace:*", "@buster/env-utils": "workspace:*",
"@buster/typescript-config": "workspace:*", "@buster/typescript-config": "workspace:*",
"@buster/vitest-config": "workspace:*", "@buster/vitest-config": "workspace:*",
"@buster-app/supabase": "workspace:*",
"ai": "catalog:", "ai": "catalog:",
"drizzle-kit": "^0.31.4", "drizzle-kit": "^0.31.4",
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",

View File

@ -1,48 +0,0 @@
#!/usr/bin/env tsx
import { exec } from 'node:child_process';
import { promisify } from 'node:util';
const execAsync = promisify(exec);
async function runCommand(command: string): Promise<void> {
console.log(`🔄 Running: ${command}`);
try {
const { stdout, stderr } = await execAsync(command);
if (stdout) {
console.log(stdout);
}
if (stderr) {
console.error(stderr);
}
console.log(`✅ Successfully completed: ${command}\n`);
} catch (error) {
console.error(`❌ Error running command: ${command}`);
console.error(error);
throw error;
}
}
async function startSupabase(): Promise<void> {
console.log('🚀 Starting Supabase setup...\n');
try {
// Start Supabase
await runCommand('npm run db:start-supabase');
// Reset the database
await runCommand('npm run db:reset');
console.log('🎉 Supabase setup completed successfully!');
} catch (error) {
console.error('💥 Supabase setup failed:', error);
process.exit(1);
}
}
// Run the script
startSupabase();

View File

@ -1,4 +0,0 @@
# Supabase
.branches
.temp
.env

View File

@ -9,25 +9,17 @@
"db:init": { "db:init": {
"cache": false, "cache": false,
"persistent": false, "persistent": false,
"dependsOn": ["db:seed"] "dependsOn": ["db:seed", "@buster-app/supabase#start"]
},
"start": {
"cache": false,
"persistent": false,
"outputs": []
},
"db:reset": {
"cache": false,
"persistent": false
}, },
"db:migrate": { "db:migrate": {
"cache": false, "cache": false,
"persistent": false "persistent": false,
"dependsOn": ["@buster-app/supabase#start"]
}, },
"db:seed": { "db:seed": {
"cache": false, "cache": false,
"persistent": false, "persistent": false,
"dependsOn": ["db:migrate"] "dependsOn": ["db:migrate", "@buster-app/supabase#start"]
}, },
"db:dump": { "db:dump": {
"cache": false, "cache": false,
@ -45,14 +37,9 @@
"cache": false, "cache": false,
"persistent": true "persistent": true
}, },
"db:stop": {
"cache": false,
"persistent": false
},
"dev": { "dev": {
"cache": false, "cache": false,
"persistent": true, "persistent": true
"dependsOn": ["start"]
} }
} }
} }

View File

@ -329,11 +329,7 @@ importers:
specifier: ^4.17.12 specifier: ^4.17.12
version: 4.17.12 version: 4.17.12
apps/supabase: apps/supabase: {}
dependencies:
'@buster/database':
specifier: workspace:*
version: link:../../packages/database
apps/trigger: apps/trigger:
dependencies: dependencies:
@ -1159,6 +1155,9 @@ importers:
packages/database: packages/database:
dependencies: dependencies:
'@buster-app/supabase':
specifier: workspace:*
version: link:../../apps/supabase
'@buster/env-utils': '@buster/env-utils':
specifier: workspace:* specifier: workspace:*
version: link:../env-utils version: link:../env-utils