Compare commits

...

17 Commits

Author SHA1 Message Date
2c7c40697a Use nextjs compile and generate to replace env without running build 2025-08-25 22:37:02 +05:30
69df4089b0 Update readme steps to test deployment locally 2025-08-23 13:30:07 +05:30
0bf6a73570 Update readme steps to deploy app 2025-08-22 22:00:35 +05:30
4ead8033d6 Set port 80 for server start in deployer 2025-08-22 22:00:34 +05:30
8f54da2b02 Update deployment readme with bond id 2025-08-22 22:00:10 +05:30
c0e3e31a81 Add build and run scripts to be used by webapp-deployer 2025-08-22 22:00:09 +05:30
9aa34090ba Update deployer script for new frontend app 2025-08-22 21:00:03 +05:30
67f2ddbb53 Update run-webapp.sh 2025-08-22 12:26:11 +00:00
Your Name
96fb9eee7c fix urls 2025-08-21 22:26:35 +00:00
Your Name
83566f09f5 use correct bakend 2025-08-21 21:51:12 +00:00
Your Name
c816d479b4 chmod plus x 2025-08-21 20:22:09 +00:00
2d51b67e58 Update build-webapp.sh 2025-08-21 20:10:14 +00:00
e1a7c2bd95 Update build-webapp.sh 2025-08-21 19:54:36 +00:00
Your Name
7cb0a0048b working 2025-08-21 19:31:07 +00:00
ace6f04064 Update build-webapp.sh 2025-08-21 19:08:39 +00:00
Your Name
49add18d4e add build & run scripts 2025-08-21 18:59:01 +00:00
Your Name
11c9273538 rm apps/backend 2025-08-21 18:56:01 +00:00
69 changed files with 1052 additions and 9018 deletions

View File

@ -1,76 +0,0 @@
# backend
This backend is a [node.js](https://nodejs.org/) [express.js](https://expressjs.com/) [apollo server](https://www.apollographql.com/docs/apollo-server/) project in a [yarn workspace](https://yarnpkg.com/features/workspaces).
## Getting Started
### Install dependencies
In the root of the project, run:
```zsh
yarn
```
### Build backend
```zsh
yarn build --ignore frontend
```
### Environment variables
#### Local
Copy the `environments/local.toml.example` file to `environments/local.toml`:
```zsh
cp environments/local.toml.example environments/local.toml
```
#### Staging environment variables
In the deployment repository, update staging [staging/configmaps/config/prod.toml](https://git.vdb.to/cerc-io/snowballtools-base-api-deployments/src/commit/318c2bc09f334dca79c3501838512749f9431bf1/deployments/staging/configmaps/config/prod.toml)
#### Production environment variables
In the deployment repository, update production [production/configmaps/config/prod.toml](https://git.vdb.to/cerc-io/snowballtools-base-api-deployments/src/commit/318c2bc09f334dca79c3501838512749f9431bf1/deployments/production/configmaps/config/prod.toml)
### Run development server
```zsh
yarn start
```
## Deployment
Clone the [deployer repository](https://git.vdb.to/cerc-io/snowballtools-base-api-deployments):
```zsh
git clone git@git.vdb.to:cerc-io/snowballtools-base-api-deployments.git
```
### Staging
```zsh
echo trigger >> .gitea/workflows/triggers/staging-deploy
git commit -a -m "Deploy v0.0.8" # replace with version number
git push
```
### Production
```zsh
echo trigger >> .gitea/workflows/triggers/production-deploy
git commit -a -m "Deploy v0.0.8" # replace with version number
git push
```
### Deployment status
Dumb for now
- [Staging](https://snowballtools-base-api.staging.apps.snowballtools.com/staging/version)
- [Production](https://snowballtools-base-api.apps.snowballtools.com/staging/version)
Update version number manually in [routes/staging.ts](/packages/backend/src/routes/staging.ts)

View File

@ -1,32 +0,0 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 80
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"suspicious": {
"noExplicitAny": "off"
},
"style": {
"noNonNullAssertion": "off"
}
}
},
"javascript": {
"formatter": {
"enabled": true,
"quoteStyle": "single",
"trailingCommas": "none",
"semicolons": "asNeeded"
}
},
"files": {
"ignore": ["dist/**/*", "node_modules/**/*", ".turbo/**/*"]
}
}

View File

@ -1,43 +0,0 @@
[server]
host = "127.0.0.1"
port = 8000
gqlPath = "/graphql"
[server.session]
secret = ""
# Frontend webapp URL origin
appOriginUrl = "http://localhost:3000"
# Set to true if server running behind proxy
trustProxy = false
# Backend URL hostname
domain = "localhost"
[database]
dbPath = "db/snowball"
[gitHub]
webhookUrl = ""
[gitHub.oAuth]
clientId = ""
clientSecret = ""
[registryConfig]
fetchDeploymentRecordDelay = 5000
checkAuctionStatusDelay = 5000
restEndpoint = "http://localhost:1317"
gqlEndpoint = "http://localhost:9473/api"
chainId = "laconic_9000-1"
privateKey = ""
bondId = ""
authority = ""
[registryConfig.fee]
gas = ""
fees = ""
gasPrice = "1alnt"
# Durations are set to 2 mins as deployers may take time with ongoing deployments and auctions
[auction]
commitFee = "100000"
commitsDuration = "120s"
revealFee = "100000"
revealsDuration = "120s"
denom = "alnt"

View File

@ -1,43 +0,0 @@
[server]
host = "127.0.0.1"
port = 8000
gqlPath = "/graphql"
[server.session]
secret = ""
# Frontend webapp URL origin
appOriginUrl = "http://localhost:3000"
# Set to true if server running behind proxy
trustProxy = false
# Backend URL hostname
domain = "localhost"
[database]
dbPath = "db/snowball"
[gitHub]
webhookUrl = ""
[gitHub.oAuth]
clientId = ""
clientSecret = ""
[registryConfig]
fetchDeploymentRecordDelay = 5000
checkAuctionStatusDelay = 5000
restEndpoint = "http://localhost:1317"
gqlEndpoint = "http://localhost:9473/api"
chainId = "laconic_9000-1"
privateKey = ""
bondId = ""
authority = ""
[registryConfig.fee]
gas = ""
fees = ""
gasPrice = "1alnt"
# Durations are set to 2 mins as deployers may take time with ongoing deployments and auctions
[auction]
commitFee = "100000"
commitsDuration = "120s"
revealFee = "100000"
revealsDuration = "120s"
denom = "alnt"

View File

@ -1,68 +0,0 @@
{
"name": "@qwrk/backend",
"license": "UNLICENSED",
"version": "1.0.0",
"private": true,
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"dependencies": {
"@cerc-io/registry-sdk": "^0.2.11",
"@cosmjs/stargate": "^0.33.0",
"@graphql-tools/schema": "^10.0.2",
"@graphql-tools/utils": "^10.0.12",
"@octokit/oauth-app": "^6.1.0",
"@turnkey/sdk-server": "^0.1.0",
"@types/debug": "^4.1.5",
"@types/node": "^20.11.0",
"@types/semver": "^7.5.8",
"apollo-server-core": "^3.13.0",
"apollo-server-express": "^3.13.0",
"cookie-session": "^2.1.0",
"cors": "^2.8.5",
"debug": "^4.3.1",
"express": "^4.18.2",
"express-async-errors": "^3.1.1",
"express-session": "^1.18.0",
"fs-extra": "^11.2.0",
"graphql": "^16.8.1",
"luxon": "^3.5.0",
"nanoid": "3",
"nanoid-dictionary": "^5.0.0-beta.1",
"octokit": "^3.1.2",
"openpgp": "^6.0.1",
"reflect-metadata": "^0.2.1",
"semver": "^7.6.0",
"siwe": "^3.0.0",
"toml": "^3.0.0",
"ts-node": "^10.9.2",
"typeorm": "^0.3.19",
"typescript": "^5.3.3"
},
"scripts": {
"start": "DEBUG=snowball:* node --enable-source-maps ./dist/index.js",
"start:dev": "DEBUG=snowball:* ts-node ./src/index.ts",
"copy-assets": "copyfiles -u 1 src/**/*.gql dist/",
"clean": "rm -rf ./dist",
"build": "pnpm clean && tsc && pnpm copy-assets",
"format": "biome format .",
"format:check": "biome format --check .",
"lint": "biome check .",
"test:registry:init": "DEBUG=snowball:* ts-node ./test/initialize-registry.ts",
"test:registry:publish-deploy-records": "DEBUG=snowball:* ts-node ./test/publish-deploy-records.ts",
"test:registry:publish-deployment-removal-records": "DEBUG=snowball:* ts-node ./test/publish-deployment-removal-records.ts",
"test:db:load:fixtures": "DEBUG=snowball:* ts-node ./test/initialize-db.ts",
"test:db:delete": "DEBUG=snowball:* ts-node ./test/delete-db.ts"
},
"devDependencies": {
"@biomejs/biome": "1.9.4",
"@types/cookie-session": "^2.0.49",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/express-session": "^1.17.10",
"@types/fs-extra": "^11.0.4",
"better-sqlite3": "^9.2.2",
"copyfiles": "^2.4.1",
"prettier": "^3.1.1",
"workspace": "^0.0.1-preview.1"
}
}

View File

@ -1,66 +0,0 @@
export interface SessionConfig {
secret: string
appOriginUrl: string
trustProxy: boolean
domain: string
}
export interface ServerConfig {
host: string
port: number
gqlPath?: string
sessionSecret: string
appOriginUrl: string
isProduction: boolean
session: SessionConfig
}
export interface DatabaseConfig {
dbPath: string
}
export interface GitHubConfig {
webhookUrl: string
oAuth: {
clientId: string
clientSecret: string
}
}
export interface RegistryConfig {
restEndpoint: string
gqlEndpoint: string
chainId: string
privateKey: string
bondId: string
fetchDeploymentRecordDelay: number
checkAuctionStatusDelay: number
authority: string
fee: {
gas: string
fees: string
gasPrice: string
}
}
export interface AuctionConfig {
commitFee: string
commitsDuration: string
revealFee: string
revealsDuration: string
denom: string
}
export interface Config {
server: ServerConfig
database: DatabaseConfig
gitHub: GitHubConfig
registryConfig: RegistryConfig
auction: AuctionConfig
turnkey: {
apiBaseUrl: string
apiPublicKey: string
apiPrivateKey: string
defaultOrganizationId: string
}
}

View File

@ -1,7 +0,0 @@
import process from 'node:process'
export const DEFAULT_CONFIG_FILE_PATH =
process.env.SNOWBALL_BACKEND_CONFIG_FILE_PATH ||
'apps/backend/environments/local.toml'
export const DEFAULT_GQL_PATH = '/graphql'

View File

@ -1,694 +0,0 @@
import assert from 'node:assert'
import path from 'node:path'
import debug from 'debug'
import { customAlphabet } from 'nanoid'
import { lowercase, numbers } from 'nanoid-dictionary'
import {
DataSource,
type DeepPartial,
type FindManyOptions,
type FindOneOptions,
type FindOptionsWhere,
IsNull,
Not
} from 'typeorm'
import type { DatabaseConfig } from './config'
import { Deployer } from './entity/Deployer'
import { Deployment, DeploymentStatus } from './entity/Deployment'
import { Domain } from './entity/Domain'
import { EnvironmentVariable } from './entity/EnvironmentVariable'
import { Organization } from './entity/Organization'
import { Project } from './entity/Project'
import { ProjectMember } from './entity/ProjectMember'
import { User } from './entity/User'
import { UserOrganization } from './entity/UserOrganization'
import type { DNSRecordAttributes } from './types'
import { getEntities, loadAndSaveData } from './utils'
const ORGANIZATION_DATA_PATH = '../test/fixtures/organizations.json'
const log = debug('snowball:database')
const nanoid = customAlphabet(lowercase + numbers, 8)
// TODO: Fix order of methods
export class Database {
private dataSource: DataSource
constructor({ dbPath }: DatabaseConfig) {
this.dataSource = new DataSource({
type: 'better-sqlite3',
database: dbPath,
entities: [path.join(__dirname, '/entity/*')],
synchronize: true,
logging: false
})
}
async init(): Promise<void> {
await this.dataSource.initialize()
log('database initialized')
let organizations = await this.getOrganizations({})
// Load an organization if none exist
if (!organizations.length) {
const orgEntities = await getEntities(
path.resolve(__dirname, ORGANIZATION_DATA_PATH)
)
organizations = await loadAndSaveData(Organization, this.dataSource, [
orgEntities[0]
])
}
// Hotfix for updating old DB data
if (organizations[0].slug === 'snowball-tools-1') {
const [orgEntity] = await getEntities(
path.resolve(__dirname, ORGANIZATION_DATA_PATH)
)
await this.updateOrganization(organizations[0].id, {
slug: orgEntity.slug as string,
name: orgEntity.name as string
})
}
}
async getUser(options: FindOneOptions<User>): Promise<User | null> {
const userRepository = this.dataSource.getRepository(User)
const user = await userRepository.findOne(options)
return user
}
async addUser(data: DeepPartial<User>): Promise<User> {
const userRepository = this.dataSource.getRepository(User)
const user = await userRepository.save(data)
return user
}
async updateUser(user: User, data: DeepPartial<User>): Promise<boolean> {
const userRepository = this.dataSource.getRepository(User)
const updateResult = await userRepository.update({ id: user.id }, data)
assert(updateResult.affected)
return updateResult.affected > 0
}
async getOrganizations(
options: FindManyOptions<Organization>
): Promise<Organization[]> {
const organizationRepository = this.dataSource.getRepository(Organization)
const organizations = await organizationRepository.find(options)
return organizations
}
async getOrganization(
options: FindOneOptions<Organization>
): Promise<Organization | null> {
const organizationRepository = this.dataSource.getRepository(Organization)
const organization = await organizationRepository.findOne(options)
return organization
}
async getOrganizationsByUserId(userId: string): Promise<Organization[]> {
const organizationRepository = this.dataSource.getRepository(Organization)
const userOrgs = await organizationRepository.find({
where: {
userOrganizations: {
member: {
id: userId
}
}
}
})
return userOrgs
}
async addUserOrganization(
data: DeepPartial<UserOrganization>
): Promise<UserOrganization> {
const userOrganizationRepository =
this.dataSource.getRepository(UserOrganization)
const newUserOrganization = await userOrganizationRepository.save(data)
return newUserOrganization
}
async updateOrganization(
organizationId: string,
data: DeepPartial<Organization>
): Promise<boolean> {
const organizationRepository = this.dataSource.getRepository(Organization)
const updateResult = await organizationRepository.update(
{ id: organizationId },
data
)
assert(updateResult.affected)
return updateResult.affected > 0
}
async getProjects(options: FindManyOptions<Project>): Promise<Project[]> {
const projectRepository = this.dataSource.getRepository(Project)
const projects = await projectRepository.find(options)
return projects
}
async getProjectById(projectId: string): Promise<Project | null> {
const projectRepository = this.dataSource.getRepository(Project)
const project = await projectRepository
.createQueryBuilder('project')
.leftJoinAndSelect(
'project.deployments',
'deployments',
'deployments.isCurrent = true AND deployments.isCanonical = true'
)
.leftJoinAndSelect('deployments.createdBy', 'user')
.leftJoinAndSelect('deployments.deployer', 'deployer')
.leftJoinAndSelect('project.owner', 'owner')
.leftJoinAndSelect('project.deployers', 'deployers')
.leftJoinAndSelect('project.organization', 'organization')
.where('project.id = :projectId', {
projectId
})
.getOne()
return project
}
async allProjectsWithoutDeployments(): Promise<Project[]> {
const allProjects = await this.getProjects({
where: {
auctionId: Not(IsNull())
},
relations: ['deployments'],
withDeleted: true
})
const projects = allProjects.filter((project) => {
if (project.deletedAt !== null) return false
return project.deployments.length === 0
})
return projects
}
async getProjectsInOrganization(
userId: string,
organizationSlug: string
): Promise<Project[]> {
const projectRepository = this.dataSource.getRepository(Project)
const projects = await projectRepository
.createQueryBuilder('project')
.leftJoinAndSelect(
'project.deployments',
'deployments',
'deployments.isCurrent = true AND deployments.isCanonical = true'
)
.leftJoin('project.projectMembers', 'projectMembers')
.leftJoin('project.organization', 'organization')
.where(
'(project.ownerId = :userId OR projectMembers.userId = :userId) AND organization.slug = :organizationSlug',
{
userId,
organizationSlug
}
)
.getMany()
return projects
}
/**
* Get deployments with specified filter
*/
async getDeployments(
options: FindManyOptions<Deployment>
): Promise<Deployment[]> {
const deploymentRepository = this.dataSource.getRepository(Deployment)
const deployments = await deploymentRepository.find(options)
return deployments
}
async getDeploymentsByProjectId(projectId: string): Promise<Deployment[]> {
return this.getDeployments({
relations: {
project: true,
createdBy: true,
deployer: true
},
where: {
project: {
id: projectId
}
},
order: {
createdAt: 'DESC'
}
})
}
async getNonCanonicalDeploymentsByProjectId(
projectId: string
): Promise<Deployment[]> {
return this.getDeployments({
relations: {
project: true,
createdBy: true,
deployer: true
},
where: {
project: {
id: projectId
},
isCanonical: false
},
order: {
createdAt: 'DESC'
}
})
}
async getDeployment(
options: FindOneOptions<Deployment>
): Promise<Deployment | null> {
const deploymentRepository = this.dataSource.getRepository(Deployment)
const deployment = await deploymentRepository.findOne(options)
return deployment
}
async getDomains(options: FindManyOptions<Domain>): Promise<Domain[]> {
const domainRepository = this.dataSource.getRepository(Domain)
const domains = await domainRepository.find(options)
return domains
}
async addDeployment(data: DeepPartial<Deployment>): Promise<Deployment> {
const deploymentRepository = this.dataSource.getRepository(Deployment)
const id = nanoid()
const updatedData = {
...data,
id
}
const deployment = await deploymentRepository.save(updatedData)
return deployment
}
async getProjectMembersByProjectId(
projectId: string
): Promise<ProjectMember[]> {
const projectMemberRepository = this.dataSource.getRepository(ProjectMember)
const projectMembers = await projectMemberRepository.find({
relations: {
project: true,
member: true
},
where: {
project: {
id: projectId
}
}
})
return projectMembers
}
async getEnvironmentVariablesByProjectId(
projectId: string,
filter?: FindOptionsWhere<EnvironmentVariable>
): Promise<EnvironmentVariable[]> {
const environmentVariableRepository =
this.dataSource.getRepository(EnvironmentVariable)
const environmentVariables = await environmentVariableRepository.find({
where: {
project: {
id: projectId
},
...filter
}
})
return environmentVariables
}
async removeProjectMemberById(projectMemberId: string): Promise<boolean> {
const projectMemberRepository = this.dataSource.getRepository(ProjectMember)
const deleteResult = await projectMemberRepository.delete({
id: projectMemberId
})
if (deleteResult.affected) {
return deleteResult.affected > 0
}
return false
}
async updateProjectMemberById(
projectMemberId: string,
data: DeepPartial<ProjectMember>
): Promise<boolean> {
const projectMemberRepository = this.dataSource.getRepository(ProjectMember)
const updateResult = await projectMemberRepository.update(
{ id: projectMemberId },
data
)
return Boolean(updateResult.affected)
}
async addProjectMember(
data: DeepPartial<ProjectMember>
): Promise<ProjectMember> {
const projectMemberRepository = this.dataSource.getRepository(ProjectMember)
const newProjectMember = await projectMemberRepository.save(data)
return newProjectMember
}
async addEnvironmentVariables(
data: DeepPartial<EnvironmentVariable>[]
): Promise<EnvironmentVariable[]> {
const environmentVariableRepository =
this.dataSource.getRepository(EnvironmentVariable)
const savedEnvironmentVariables =
await environmentVariableRepository.save(data)
return savedEnvironmentVariables
}
async updateEnvironmentVariable(
environmentVariableId: string,
data: DeepPartial<EnvironmentVariable>
): Promise<boolean> {
const environmentVariableRepository =
this.dataSource.getRepository(EnvironmentVariable)
const updateResult = await environmentVariableRepository.update(
{ id: environmentVariableId },
data
)
return Boolean(updateResult.affected)
}
async deleteEnvironmentVariable(
environmentVariableId: string
): Promise<boolean> {
const environmentVariableRepository =
this.dataSource.getRepository(EnvironmentVariable)
const deleteResult = await environmentVariableRepository.delete({
id: environmentVariableId
})
if (deleteResult.affected) {
return deleteResult.affected > 0
}
return false
}
async getProjectMemberById(projectMemberId: string): Promise<ProjectMember> {
const projectMemberRepository = this.dataSource.getRepository(ProjectMember)
const projectMemberWithProject = await projectMemberRepository.find({
relations: {
project: {
owner: true
},
member: true
},
where: {
id: projectMemberId
}
})
if (projectMemberWithProject.length === 0) {
throw new Error('Member does not exist')
}
return projectMemberWithProject[0]
}
async getProjectsBySearchText(
userId: string,
searchText: string
): Promise<Project[]> {
const projectRepository = this.dataSource.getRepository(Project)
const projects = await projectRepository
.createQueryBuilder('project')
.leftJoinAndSelect('project.organization', 'organization')
.leftJoin('project.projectMembers', 'projectMembers')
.where(
'(project.owner = :userId OR projectMembers.member.id = :userId) AND project.name LIKE :searchText',
{
userId,
searchText: `%${searchText}%`
}
)
.getMany()
return projects
}
async updateDeploymentById(
deploymentId: string,
data: DeepPartial<Deployment>
): Promise<boolean> {
return this.updateDeployment({ id: deploymentId }, data)
}
async updateDeployment(
criteria: FindOptionsWhere<Deployment>,
data: DeepPartial<Deployment>
): Promise<boolean> {
const deploymentRepository = this.dataSource.getRepository(Deployment)
const updateResult = await deploymentRepository.update(criteria, data)
return Boolean(updateResult.affected)
}
async updateDeploymentsByProjectIds(
projectIds: string[],
data: DeepPartial<Deployment>
): Promise<boolean> {
const deploymentRepository = this.dataSource.getRepository(Deployment)
const updateResult = await deploymentRepository
.createQueryBuilder()
.update(Deployment)
.set(data)
.where('projectId IN (:...projectIds)', { projectIds })
.execute()
return Boolean(updateResult.affected)
}
async deleteDeploymentById(deploymentId: string): Promise<boolean> {
const deploymentRepository = this.dataSource.getRepository(Deployment)
const deployment = await deploymentRepository.findOneOrFail({
where: {
id: deploymentId
}
})
const deleteResult = await deploymentRepository.softRemove(deployment)
return Boolean(deleteResult)
}
async addProject(
user: User,
organizationId: string,
data: DeepPartial<Project>
): Promise<Project> {
const projectRepository = this.dataSource.getRepository(Project)
// TODO: Check if organization exists
const newProject = projectRepository.create(data)
// TODO: Set default empty array for webhooks in TypeORM
newProject.webhooks = []
// TODO: Set icon according to framework
newProject.icon = ''
newProject.owner = user
newProject.organization = Object.assign(new Organization(), {
id: organizationId
})
return projectRepository.save(newProject)
}
async saveProject(project: Project): Promise<Project> {
const projectRepository = this.dataSource.getRepository(Project)
return projectRepository.save(project)
}
async updateProjectById(
projectId: string,
data: DeepPartial<Project>
): Promise<boolean> {
const projectRepository = this.dataSource.getRepository(Project)
const updateResult = await projectRepository.update({ id: projectId }, data)
return Boolean(updateResult.affected)
}
async deleteProjectById(projectId: string): Promise<boolean> {
const projectRepository = this.dataSource.getRepository(Project)
const project = await projectRepository.findOneOrFail({
where: {
id: projectId
},
relations: {
projectMembers: true
}
})
const deleteResult = await projectRepository.softRemove(project)
return Boolean(deleteResult)
}
async deleteDomainById(domainId: string): Promise<boolean> {
const domainRepository = this.dataSource.getRepository(Domain)
const deleteResult = await domainRepository.softDelete({ id: domainId })
if (deleteResult.affected) {
return deleteResult.affected > 0
}
return false
}
async addDomain(data: DeepPartial<Domain>): Promise<Domain> {
const domainRepository = this.dataSource.getRepository(Domain)
const newDomain = await domainRepository.save(data)
return newDomain
}
async getDomain(options: FindOneOptions<Domain>): Promise<Domain | null> {
const domainRepository = this.dataSource.getRepository(Domain)
const domain = await domainRepository.findOne(options)
return domain
}
async updateDomainById(
domainId: string,
data: DeepPartial<Domain>
): Promise<boolean> {
const domainRepository = this.dataSource.getRepository(Domain)
const updateResult = await domainRepository.update({ id: domainId }, data)
return Boolean(updateResult.affected)
}
async getDomainsByProjectId(
projectId: string,
filter?: FindOptionsWhere<Domain>
): Promise<Domain[]> {
const domainRepository = this.dataSource.getRepository(Domain)
const domains = await domainRepository.find({
relations: {
redirectTo: true
},
where: {
project: {
id: projectId
},
...filter
}
})
return domains
}
async getOldestDomainByProjectId(projectId: string): Promise<Domain | null> {
const domainRepository = this.dataSource.getRepository(Domain)
const domain = await domainRepository.findOne({
where: {
project: {
id: projectId
}
},
order: {
createdAt: 'ASC'
}
})
return domain
}
async getLatestDNSRecordByProjectId(
projectId: string
): Promise<DNSRecordAttributes | null> {
const deploymentRepository = this.dataSource.getRepository(Deployment)
const deployment = await deploymentRepository.findOne({
where: {
project: {
id: projectId
},
status: DeploymentStatus.Ready
},
order: {
createdAt: 'DESC'
}
})
if (deployment === null) {
throw new Error(`No deployment found for project ${projectId}`)
}
return deployment.dnsRecordData
}
async addDeployer(data: DeepPartial<Deployer>): Promise<Deployer> {
const deployerRepository = this.dataSource.getRepository(Deployer)
const newDomain = await deployerRepository.save(data)
return newDomain
}
async getDeployers(): Promise<Deployer[]> {
const deployerRepository = this.dataSource.getRepository(Deployer)
const deployers = await deployerRepository.find()
return deployers
}
async getDeployerByLRN(deployerLrn: string): Promise<Deployer | null> {
const deployerRepository = this.dataSource.getRepository(Deployer)
const deployer = await deployerRepository.findOne({
where: { deployerLrn }
})
return deployer
}
}

View File

@ -1,32 +0,0 @@
import { Column, Entity, ManyToMany, PrimaryColumn } from 'typeorm'
import { Project } from './Project'
@Entity()
export class Deployer {
@PrimaryColumn('varchar')
deployerLrn!: string
@Column('varchar')
deployerId!: string
@Column('varchar')
deployerApiUrl!: string
@Column('varchar')
baseDomain!: string
@Column('varchar', { nullable: true })
publicKey!: string | null
@Column('varchar', { nullable: true })
minimumPayment!: string | null
@Column('varchar', { nullable: true })
paymentAddress!: string | null
@ManyToMany(
() => Project,
(project) => project.deployers
)
projects!: Project[]
}

View File

@ -1,159 +0,0 @@
import {
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryColumn,
UpdateDateColumn
} from 'typeorm'
import type {
AppDeploymentRecordAttributes,
AppDeploymentRemovalRecordAttributes,
DNSRecordAttributes
} from '../types'
import { Deployer } from './Deployer'
import { Project } from './Project'
import { User } from './User'
export enum Environment {
Production = 'Production',
Preview = 'Preview',
Development = 'Development'
}
export enum DeploymentStatus {
Building = 'Building',
Ready = 'Ready',
Error = 'Error',
Deleting = 'Deleting'
}
export interface ApplicationDeploymentRequest {
type: string
version: string
name: string
application: string
lrn?: string
auction?: string
config: string
meta: string
payment?: string
dns?: string
}
export interface ApplicationDeploymentRemovalRequest {
type: string
version: string
deployment: string
auction?: string
payment?: string
}
export interface ApplicationRecord {
type: string
version: string
name: string
description?: string
homepage?: string
license?: string
author?: string
repository?: string[]
app_version?: string
repository_ref: string
app_type: string
}
@Entity()
export class Deployment {
// TODO: set custom generated id
@PrimaryColumn('varchar')
id!: string
@Column()
projectId!: string
@ManyToOne(() => Project, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'projectId' })
project!: Project
@Column('varchar')
branch!: string
@Column('varchar')
commitHash!: string
@Column('varchar')
commitMessage!: string
@Column('varchar', { nullable: true })
url!: string | null
@Column('varchar')
applicationRecordId!: string
@Column('simple-json')
applicationRecordData!: ApplicationRecord
@Column('varchar', { nullable: true })
applicationDeploymentRequestId!: string | null
@Column('simple-json', { nullable: true })
applicationDeploymentRequestData!: ApplicationDeploymentRequest | null
@Column('varchar', { nullable: true })
applicationDeploymentRecordId!: string | null
@Column('simple-json', { nullable: true })
applicationDeploymentRecordData!: AppDeploymentRecordAttributes | null
@Column('varchar', { nullable: true })
applicationDeploymentRemovalRequestId!: string | null
@Column('simple-json', { nullable: true })
applicationDeploymentRemovalRequestData!: ApplicationDeploymentRemovalRequest | null
@Column('varchar', { nullable: true })
applicationDeploymentRemovalRecordId!: string | null
@Column('simple-json', { nullable: true })
applicationDeploymentRemovalRecordData!: AppDeploymentRemovalRecordAttributes | null
@Column('simple-json', { nullable: true })
dnsRecordData!: DNSRecordAttributes | null
@ManyToOne(() => Deployer)
@JoinColumn({ name: 'deployerLrn' })
deployer!: Deployer
@Column({
enum: Environment
})
environment!: Environment
@Column('boolean', { default: false })
isCurrent!: boolean
@Column('boolean', { default: false })
isCanonical!: boolean
@Column({
enum: DeploymentStatus
})
status!: DeploymentStatus
@ManyToOne(() => User)
@JoinColumn({ name: 'createdBy' })
createdBy!: User
@CreateDateColumn()
createdAt!: Date
@UpdateDateColumn()
updatedAt!: Date
@DeleteDateColumn()
deletedAt!: Date | null
}

View File

@ -1,59 +0,0 @@
import {
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn
} from 'typeorm'
import { Project } from './Project'
export enum Status {
Live = 'Live',
Pending = 'Pending'
}
@Entity()
export class Domain {
@PrimaryGeneratedColumn('uuid')
id!: string
@Column('varchar')
projectId!: string
@ManyToOne(() => Project, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'projectId' })
project!: Project
@Column('varchar', { length: 255, default: 'main' })
branch!: string
@Column('varchar', { length: 255 })
name!: string
@Column('string', { nullable: true })
redirectToId!: string | null
@ManyToOne(() => Domain)
@JoinColumn({ name: 'redirectToId' })
// eslint-disable-next-line no-use-before-define
redirectTo!: Domain | null
@Column({
enum: Status,
default: Status.Pending
})
status!: Status
@CreateDateColumn()
createdAt!: Date
@UpdateDateColumn()
updatedAt!: Date
@DeleteDateColumn()
deletedAt!: Date | null
}

View File

@ -1,44 +0,0 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn
} from 'typeorm'
import { Project } from './Project'
enum Environment {
Production = 'Production',
Preview = 'Preview',
Development = 'Development'
}
@Entity()
export class EnvironmentVariable {
@PrimaryGeneratedColumn('uuid')
id!: string
@ManyToOne(() => Project, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'projectId' })
project!: Project
@Column({
enum: Environment
})
environment!: Environment
@Column('varchar')
key!: string
@Column('varchar')
value!: string
@CreateDateColumn()
createdAt!: Date
@UpdateDateColumn()
updatedAt!: Date
}

View File

@ -1,38 +0,0 @@
import {
Column,
CreateDateColumn,
Entity,
OneToMany,
PrimaryGeneratedColumn,
Unique,
UpdateDateColumn
} from 'typeorm'
import { UserOrganization } from './UserOrganization'
@Entity()
@Unique(['slug'])
export class Organization {
@PrimaryGeneratedColumn('uuid')
id!: string
@Column('varchar', { length: 255 })
name!: string
@Column('varchar')
slug!: string
@CreateDateColumn()
createdAt!: Date
@UpdateDateColumn()
updatedAt!: Date
@OneToMany(
() => UserOrganization,
(userOrganization) => userOrganization.organization,
{
cascade: ['soft-remove']
}
)
userOrganizations!: UserOrganization[]
}

View File

@ -1,111 +0,0 @@
import {
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
JoinColumn,
JoinTable,
ManyToMany,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
UpdateDateColumn
} from 'typeorm'
import { Deployer } from './Deployer'
import { Deployment } from './Deployment'
import { Organization } from './Organization'
import { ProjectMember } from './ProjectMember'
import { User } from './User'
@Entity()
export class Project {
@PrimaryGeneratedColumn('uuid')
id!: string
@ManyToOne(() => User)
@JoinColumn({ name: 'ownerId' })
owner!: User
@Column({ nullable: false })
ownerId!: string
@ManyToOne(() => Organization, { nullable: true })
@JoinColumn({ name: 'organizationId' })
organization!: Organization | null
@Column('varchar')
organizationId!: string
@Column('varchar')
name!: string
@Column('varchar')
repository!: string
@Column('varchar', { length: 255, default: 'main' })
prodBranch!: string
@Column('text', { default: '' })
description!: string
@Column('varchar', { nullable: true })
auctionId!: string | null
// Tx hash for sending coins from snowball to deployer
@Column('varchar', { nullable: true })
txHash!: string | null
@ManyToMany(
() => Deployer,
(deployer) => deployer.projects
)
@JoinTable()
deployers!: Deployer[]
@Column('boolean', { default: false, nullable: true })
fundsReleased!: boolean
// TODO: Compute template & framework in import repository
@Column('varchar', { nullable: true })
template!: string | null
@Column('varchar', { nullable: true })
framework!: string | null
// Address of the user who created the project i.e. requested deployments
@Column('varchar')
paymentAddress!: string
@Column({
type: 'simple-array'
})
webhooks!: string[]
@Column('varchar')
icon!: string
@CreateDateColumn()
createdAt!: Date
@UpdateDateColumn()
updatedAt!: Date
@DeleteDateColumn()
deletedAt!: Date | null
@OneToMany(
() => Deployment,
(deployment) => deployment.project
)
deployments!: Deployment[]
@OneToMany(
() => ProjectMember,
(projectMember) => projectMember.project,
{
cascade: ['soft-remove']
}
)
projectMembers!: ProjectMember[]
}

View File

@ -1,57 +0,0 @@
import {
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
Unique,
UpdateDateColumn
} from 'typeorm'
import { Project } from './Project'
import { User } from './User'
export enum Permission {
View = 'View',
Edit = 'Edit'
}
@Entity()
@Unique(['project', 'member'])
export class ProjectMember {
@PrimaryGeneratedColumn('uuid')
id!: string
@ManyToOne(
() => User,
(user) => user.projectMembers
)
@JoinColumn({ name: 'userId' })
member!: User
@ManyToOne(
() => Project,
(project) => project.projectMembers
)
@JoinColumn({ name: 'projectId' })
project!: Project
@Column({
type: 'simple-array'
})
permissions!: Permission[]
@Column('boolean', { default: false })
isPending!: boolean
@CreateDateColumn()
createdAt!: Date
@UpdateDateColumn()
updatedAt!: Date
@DeleteDateColumn()
deletedAt!: Date | null
}

View File

@ -1,65 +0,0 @@
import {
Column,
CreateDateColumn,
Entity,
OneToMany,
PrimaryGeneratedColumn,
Unique
} from 'typeorm'
import { ProjectMember } from './ProjectMember'
import { UserOrganization } from './UserOrganization'
@Entity()
@Unique(['email'])
@Unique(['ethAddress'])
export class User {
@PrimaryGeneratedColumn('uuid')
id!: string
// TODO: Set ethAddress as ID
@Column()
ethAddress!: string
@Column('varchar', { length: 255, nullable: true })
name!: string | null
@Column()
email!: string
@Column('varchar', { nullable: true })
gitHubToken!: string | null
@Column('boolean', { default: false })
isVerified!: boolean
@CreateDateColumn()
createdAt!: Date
@CreateDateColumn()
updatedAt!: Date
@Column()
subOrgId!: string
@Column()
turnkeyWalletId!: string
@OneToMany(
() => ProjectMember,
(projectMember) => projectMember.project,
{
cascade: ['soft-remove']
}
)
projectMembers!: ProjectMember[]
@OneToMany(
() => UserOrganization,
(UserOrganization) => UserOrganization.member,
{
cascade: ['soft-remove']
}
)
userOrganizations!: UserOrganization[]
}

View File

@ -1,47 +0,0 @@
import {
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn
} from 'typeorm'
import { Organization } from './Organization'
import { User } from './User'
export enum Role {
Owner = 'Owner',
Maintainer = 'Maintainer',
Reader = 'Reader'
}
@Entity()
export class UserOrganization {
@PrimaryGeneratedColumn('uuid')
id!: string
@ManyToOne(() => User)
@JoinColumn({ name: 'userId' })
member!: User
@ManyToOne(() => Organization)
@JoinColumn({ name: 'organizationId' })
organization!: Organization
@Column({
enum: Role
})
role!: Role
@CreateDateColumn()
createdAt!: Date
@UpdateDateColumn()
updatedAt!: Date
@DeleteDateColumn()
deletedAt!: Date | null
}

View File

@ -1,53 +0,0 @@
import 'express-async-errors'
import 'reflect-metadata'
import fs from 'node:fs'
import path from 'node:path'
import debug from 'debug'
import { OAuthApp } from '@octokit/oauth-app'
import { Database } from './database'
import { Registry } from './registry'
import { createResolvers } from './resolvers'
import { createAndStartServer } from './server'
import { Service } from './service'
import { getConfig } from './utils'
const log = debug('snowball:server')
const OAUTH_CLIENT_TYPE = 'oauth-app'
export const main = async (): Promise<void> => {
const { server, database, gitHub, registryConfig } = await getConfig()
const app = new OAuthApp({
clientType: OAUTH_CLIENT_TYPE,
clientId: gitHub.oAuth.clientId,
clientSecret: gitHub.oAuth.clientSecret
})
const db = new Database(database)
await db.init()
const registry = new Registry(registryConfig)
const service = new Service(
{ gitHubConfig: gitHub, registryConfig },
db,
app,
registry
)
const typeDefs = fs
.readFileSync(path.join(__dirname, 'schema.gql'))
.toString()
const resolvers = await createResolvers(service)
await createAndStartServer(server, typeDefs, resolvers, service)
}
main()
.then(() => {
log('Starting server...')
})
.catch((err) => {
log(err)
})

View File

@ -1,624 +0,0 @@
import assert from 'node:assert'
import debug from 'debug'
import { DateTime } from 'luxon'
import type { Octokit } from 'octokit'
import * as openpgp from 'openpgp'
import { inc as semverInc } from 'semver'
import type { DeepPartial } from 'typeorm'
import {
Account,
DEFAULT_GAS_ESTIMATION_MULTIPLIER,
Registry as LaconicRegistry,
getGasPrice,
parseGasAndFees
} from '@cerc-io/registry-sdk'
import type { DeliverTxResponse, IndexedTx } from '@cosmjs/stargate'
import type { RegistryConfig } from './config'
import type {
ApplicationDeploymentRemovalRequest,
ApplicationDeploymentRequest,
ApplicationRecord,
Deployment
} from './entity/Deployment'
import type {
AppDeploymentRecord,
AppDeploymentRemovalRecord,
AuctionParams,
DeployerRecord,
RegistryRecord
} from './types'
import {
getConfig,
getRepoDetails,
registryTransactionWithRetry,
sleep
} from './utils'
const log = debug('snowball:registry')
const APP_RECORD_TYPE = 'ApplicationRecord'
const APP_DEPLOYMENT_AUCTION_RECORD_TYPE = 'ApplicationDeploymentAuction'
const APP_DEPLOYMENT_REQUEST_TYPE = 'ApplicationDeploymentRequest'
const APP_DEPLOYMENT_REMOVAL_REQUEST_TYPE =
'ApplicationDeploymentRemovalRequest'
const APP_DEPLOYMENT_RECORD_TYPE = 'ApplicationDeploymentRecord'
const APP_DEPLOYMENT_REMOVAL_RECORD_TYPE = 'ApplicationDeploymentRemovalRecord'
const WEBAPP_DEPLOYER_RECORD_TYPE = 'WebappDeployer'
const SLEEP_DURATION = 1000
// TODO: Move registry code to registry-sdk/watcher-ts
export class Registry {
private registry: LaconicRegistry
private registryConfig: RegistryConfig
constructor(registryConfig: RegistryConfig) {
this.registryConfig = registryConfig
const gasPrice = getGasPrice(registryConfig.fee.gasPrice)
this.registry = new LaconicRegistry(
registryConfig.gqlEndpoint,
registryConfig.restEndpoint,
{ chainId: registryConfig.chainId, gasPrice }
)
}
async createApplicationRecord({
octokit,
repository,
commitHash,
appType
}: {
octokit: Octokit
repository: string
commitHash: string
appType: string
}): Promise<{
applicationRecordId: string
applicationRecordData: ApplicationRecord
}> {
const { repo, repoUrl, packageJSON } = await getRepoDetails(
octokit,
repository,
commitHash
)
// Use registry-sdk to publish record
// Reference: https://git.vdb.to/cerc-io/test-progressive-web-app/src/branch/main/scripts/publish-app-record.sh
// Fetch previous records
const records = await this.registry.queryRecords(
{
type: APP_RECORD_TYPE,
name: packageJSON.name
},
true
)
// Get next version of record
const bondRecords = records.filter(
(record: any) => record.bondId === this.registryConfig.bondId
)
const [latestBondRecord] = bondRecords.sort(
(a: any, b: any) =>
new Date(b.createTime).getTime() - new Date(a.createTime).getTime()
)
const nextVersion = semverInc(
latestBondRecord?.attributes.version ?? '0.0.0',
'patch'
)
assert(nextVersion, 'Application record version not valid')
// Create record of type ApplicationRecord and publish
const applicationRecord = {
type: APP_RECORD_TYPE,
version: nextVersion,
repository_ref: commitHash,
repository: [repoUrl],
app_type: appType,
name: repo,
...(packageJSON.description && { description: packageJSON.description }),
...(packageJSON.homepage && { homepage: packageJSON.homepage }),
...(packageJSON.license && { license: packageJSON.license }),
...(packageJSON.author && {
author:
typeof packageJSON.author === 'object'
? JSON.stringify(packageJSON.author)
: packageJSON.author
}),
...(packageJSON.version && { app_version: packageJSON.version })
}
const result = await this.publishRecord(applicationRecord)
log(`Published application record ${result.id}`)
log('Application record data:', applicationRecord)
// TODO: Discuss computation of LRN
const lrn = this.getLrn(repo)
log(`Setting name: ${lrn} for record ID: ${result.id}`)
const fee = parseGasAndFees(
this.registryConfig.fee.gas,
this.registryConfig.fee.fees
)
await sleep(SLEEP_DURATION)
await registryTransactionWithRetry(() =>
this.registry.setName(
{
cid: result.id,
lrn
},
this.registryConfig.privateKey,
fee
)
)
await sleep(SLEEP_DURATION)
await registryTransactionWithRetry(() =>
this.registry.setName(
{
cid: result.id,
lrn: `${lrn}@${applicationRecord.app_version}`
},
this.registryConfig.privateKey,
fee
)
)
await sleep(SLEEP_DURATION)
await registryTransactionWithRetry(() =>
this.registry.setName(
{
cid: result.id,
lrn: `${lrn}@${applicationRecord.repository_ref}`
},
this.registryConfig.privateKey,
fee
)
)
return {
applicationRecordId: result.id,
applicationRecordData: applicationRecord
}
}
async createApplicationDeploymentAuction(
appName: string,
octokit: Octokit,
auctionParams: AuctionParams,
data: DeepPartial<Deployment>
): Promise<{
applicationDeploymentAuctionId: string
}> {
assert(data.project?.repository, 'Project repository not found')
await this.createApplicationRecord({
octokit,
repository: data.project.repository,
appType: data.project!.template!,
commitHash: data.commitHash!
})
const lrn = this.getLrn(appName)
const config = await getConfig()
const auctionConfig = config.auction
const fee = parseGasAndFees(
this.registryConfig.fee.gas,
this.registryConfig.fee.fees
)
const auctionResult = await registryTransactionWithRetry(() =>
this.registry.createProviderAuction(
{
commitFee: auctionConfig.commitFee,
commitsDuration: auctionConfig.commitsDuration,
revealFee: auctionConfig.revealFee,
revealsDuration: auctionConfig.revealsDuration,
denom: auctionConfig.denom,
maxPrice: auctionParams.maxPrice,
numProviders: auctionParams.numProviders
},
this.registryConfig.privateKey,
fee
)
)
if (!auctionResult.auction) {
throw new Error('Error creating auction')
}
// Create record of type applicationDeploymentAuction and publish
const applicationDeploymentAuction = {
application: lrn,
auction: auctionResult.auction.id,
type: APP_DEPLOYMENT_AUCTION_RECORD_TYPE
}
const result = await this.publishRecord(applicationDeploymentAuction)
log(`Application deployment auction created: ${auctionResult.auction.id}`)
log(`Application deployment auction record published: ${result.id}`)
log('Application deployment auction data:', applicationDeploymentAuction)
return {
applicationDeploymentAuctionId: auctionResult.auction.id
}
}
async createApplicationDeploymentRequest(data: {
deployment: Deployment
appName: string
repository: string
auctionId?: string | null
lrn: string
apiUrl: string
environmentVariables: { [key: string]: string }
dns: string
requesterAddress: string
publicKey: string
payment?: string | null
}): Promise<{
applicationDeploymentRequestId: string
applicationDeploymentRequestData: ApplicationDeploymentRequest
}> {
const lrn = this.getLrn(data.appName)
const records = await this.registry.resolveNames([lrn])
const applicationRecord = records[0]
if (!applicationRecord) {
throw new Error(`No record found for ${lrn}`)
}
let hash: string | undefined
if (Object.keys(data.environmentVariables).length !== 0) {
hash = await this.generateConfigHash(
data.environmentVariables,
data.requesterAddress,
data.publicKey,
data.apiUrl
)
}
// Create record of type ApplicationDeploymentRequest and publish
const applicationDeploymentRequest = {
type: APP_DEPLOYMENT_REQUEST_TYPE,
version: '1.0.0',
name: `${applicationRecord.attributes.name}@${applicationRecord.attributes.app_version}`,
application: `${lrn}@${applicationRecord.attributes.app_version}`,
dns: data.dns,
// https://git.vdb.to/cerc-io/laconic-registry-cli/commit/129019105dfb93bebcea02fde0ed64d0f8e5983b
config: JSON.stringify(hash ? { ref: hash } : {}),
meta: JSON.stringify({
note: `Added by Snowball @ ${DateTime.utc().toFormat(
"EEE LLL dd HH:mm:ss 'UTC' yyyy"
)}`,
repository: data.repository,
repository_ref: data.deployment.commitHash
}),
deployer: data.lrn,
...(data.auctionId && { auction: data.auctionId }),
...(data.payment && { payment: data.payment })
}
await sleep(SLEEP_DURATION)
const result = await this.publishRecord(applicationDeploymentRequest)
log(`Application deployment request record published: ${result.id}`)
log('Application deployment request data:', applicationDeploymentRequest)
return {
applicationDeploymentRequestId: result.id,
applicationDeploymentRequestData: applicationDeploymentRequest
}
}
async getAuctionWinningDeployerRecords(
auctionId: string
): Promise<DeployerRecord[]> {
const records = await this.registry.getAuctionsByIds([auctionId])
const auctionResult = records[0]
const deployerRecords = []
const { winnerAddresses } = auctionResult
for (const auctionWinner of winnerAddresses) {
const records = await this.getDeployerRecordsByFilter({
paymentAddress: auctionWinner
})
const newRecords = records.filter((record) => {
return record.names !== null && record.names.length > 0
})
for (const record of newRecords) {
if (record.id) {
deployerRecords.push(record)
break
}
}
}
return deployerRecords
}
async releaseDeployerFunds(auctionId: string): Promise<any> {
const fee = parseGasAndFees(
this.registryConfig.fee.gas,
this.registryConfig.fee.fees
)
const auction = await registryTransactionWithRetry(() =>
this.registry.releaseFunds(
{
auctionId
},
this.registryConfig.privateKey,
fee
)
)
return auction
}
/**
* Fetch ApplicationDeploymentRecords for deployments
*/
async getDeploymentRecords(
deployments: Deployment[]
): Promise<AppDeploymentRecord[]> {
// Fetch ApplicationDeploymentRecords for corresponding ApplicationRecord set in deployments
// TODO: Implement Laconicd GQL query to filter records by multiple values for an attribute
const records = await this.registry.queryRecords(
{
type: APP_DEPLOYMENT_RECORD_TYPE
},
true
)
// Filter records with ApplicationDeploymentRequestId ID
return records.filter((record: AppDeploymentRecord) =>
deployments.some(
(deployment) =>
deployment.applicationDeploymentRequestId ===
record.attributes.request
)
)
}
/**
* Fetch WebappDeployer Records by filter
*/
async getDeployerRecordsByFilter(filter: { [key: string]: any }): Promise<
DeployerRecord[]
> {
return this.registry.queryRecords(
{
type: WEBAPP_DEPLOYER_RECORD_TYPE,
...filter
},
true
)
}
/**
* Fetch ApplicationDeploymentRecords by filter
*/
async getDeploymentRecordsByFilter(filter: { [key: string]: any }): Promise<
AppDeploymentRecord[]
> {
return this.registry.queryRecords(
{
type: APP_DEPLOYMENT_RECORD_TYPE,
...filter
},
true
)
}
/**
* Fetch ApplicationDeploymentRemovalRecords for deployments
*/
async getDeploymentRemovalRecords(
deployments: Deployment[]
): Promise<AppDeploymentRemovalRecord[]> {
// Fetch ApplicationDeploymentRemovalRecords for corresponding ApplicationDeploymentRecord set in deployments
const records = await this.registry.queryRecords(
{
type: APP_DEPLOYMENT_REMOVAL_RECORD_TYPE
},
true
)
// Filter records with ApplicationDeploymentRecord and ApplicationDeploymentRemovalRequest IDs
return records.filter((record: AppDeploymentRemovalRecord) =>
deployments.some(
(deployment) =>
deployment.applicationDeploymentRemovalRequestId ===
record.attributes.request &&
deployment.applicationDeploymentRecordId ===
record.attributes.deployment
)
)
}
/**
* Fetch record by Id
*/
async getRecordById(id: string): Promise<RegistryRecord | null> {
const [record] = await this.registry.getRecordsByIds([id])
return record ?? null
}
async createApplicationDeploymentRemovalRequest(data: {
deploymentId: string
deployerLrn: string
auctionId?: string | null
payment?: string | null
}): Promise<{
applicationDeploymentRemovalRequestId: string
applicationDeploymentRemovalRequestData: ApplicationDeploymentRemovalRequest
}> {
const applicationDeploymentRemovalRequest = {
type: APP_DEPLOYMENT_REMOVAL_REQUEST_TYPE,
version: '1.0.0',
deployment: data.deploymentId,
deployer: data.deployerLrn,
...(data.auctionId && { auction: data.auctionId }),
...(data.payment && { payment: data.payment })
}
const result = await this.publishRecord(applicationDeploymentRemovalRequest)
log(`Application deployment removal request record published: ${result.id}`)
log(
'Application deployment removal request data:',
applicationDeploymentRemovalRequest
)
return {
applicationDeploymentRemovalRequestId: result.id,
applicationDeploymentRemovalRequestData:
applicationDeploymentRemovalRequest
}
}
async getCompletedAuctionIds(auctionIds: string[]): Promise<string[]> {
if (auctionIds.length === 0) {
return []
}
const auctions = await this.registry.getAuctionsByIds(auctionIds)
const completedAuctions = auctions
.filter(
(auction: { id: string; status: string }) =>
auction.status === 'completed'
)
.map((auction: { id: string; status: string }) => auction.id)
return completedAuctions
}
async publishRecord(recordData: any): Promise<any> {
const fee = parseGasAndFees(
this.registryConfig.fee.gas,
this.registryConfig.fee.fees
)
const result = await registryTransactionWithRetry(() =>
this.registry.setRecord(
{
privateKey: this.registryConfig.privateKey,
record: recordData,
bondId: this.registryConfig.bondId
},
this.registryConfig.privateKey,
fee
)
)
return result
}
async getRecordsByName(name: string): Promise<any> {
return this.registry.resolveNames([name])
}
async getAuctionData(auctionId: string): Promise<any> {
return this.registry.getAuctionsByIds([auctionId])
}
async sendTokensToAccount(
receiverAddress: string,
amount: string
): Promise<DeliverTxResponse> {
const fee = parseGasAndFees(
this.registryConfig.fee.gas,
this.registryConfig.fee.fees
)
const account = await this.getAccount()
const laconicClient = await this.registry.getLaconicClient(account)
const txResponse: DeliverTxResponse = await registryTransactionWithRetry(
() =>
laconicClient.sendTokens(
account.address,
receiverAddress,
[
{
denom: 'alnt',
amount
}
],
fee || DEFAULT_GAS_ESTIMATION_MULTIPLIER
)
)
return txResponse
}
async getAccount(): Promise<Account> {
const account = new Account(
Buffer.from(this.registryConfig.privateKey, 'hex')
)
await account.init()
return account
}
async getTxResponse(txHash: string): Promise<IndexedTx | null> {
const account = await this.getAccount()
const laconicClient = await this.registry.getLaconicClient(account)
const txResponse: IndexedTx | null = await laconicClient.getTx(txHash)
return txResponse
}
getLrn(appName: string): string {
assert(this.registryConfig.authority, "Authority doesn't exist")
return `lrn://${this.registryConfig.authority}/applications/${appName}`
}
async generateConfigHash(
environmentVariables: { [key: string]: string },
requesterAddress: string,
pubKey: string,
url: string
): Promise<string> {
// Config to be encrypted
const config = {
authorized: [requesterAddress],
config: { env: environmentVariables }
}
// Serialize the config
const serialized = JSON.stringify(config, null, 2)
const armoredKey = `-----BEGIN PGP PUBLIC KEY BLOCK-----\n\n${pubKey}\n\n-----END PGP PUBLIC KEY BLOCK-----`
const publicKey = await openpgp.readKey({ armoredKey })
// Encrypt the config
const encrypted = await openpgp.encrypt({
message: await openpgp.createMessage({ text: serialized }),
encryptionKeys: publicKey,
format: 'binary'
})
// Get the hash after uploading encrypted config
const response = await fetch(`${url}/upload/config`, {
method: 'POST',
headers: {
'Content-Type': 'application/octet-stream'
},
body: encrypted
})
const configHash = await response.json()
return configHash.id
}
}

View File

@ -1,413 +0,0 @@
import debug from 'debug'
import type { DeepPartial, FindOptionsWhere } from 'typeorm'
import type { Domain } from './entity/Domain'
import type { EnvironmentVariable } from './entity/EnvironmentVariable'
import type { Project } from './entity/Project'
import type { Permission } from './entity/ProjectMember'
import type { Service } from './service'
import type {
AddProjectFromTemplateInput,
AuctionParams,
EnvironmentVariables
} from './types'
const log = debug('snowball:resolver')
export const createResolvers = async (service: Service): Promise<any> => {
return {
Query: {
// TODO: add custom type for context
user: (_: any, __: any, context: any) => {
return context.user
},
organizations: async (_: any, __: any, context: any) => {
return service.getOrganizationsByUserId(context.user)
},
project: async (
_: any,
{ projectId }: { projectId: string },
context: any
) => {
return service.getProjectById(context.user, projectId)
},
projectsInOrganization: async (
_: any,
{ organizationSlug }: { organizationSlug: string },
context: any
) => {
return service.getProjectsInOrganization(context.user, organizationSlug)
},
deployments: async (_: any, { projectId }: { projectId: string }) => {
return service.getNonCanonicalDeploymentsByProjectId(projectId)
},
environmentVariables: async (
_: any,
{ projectId }: { projectId: string }
) => {
return service.getEnvironmentVariablesByProjectId(projectId)
},
projectMembers: async (_: any, { projectId }: { projectId: string }) => {
return service.getProjectMembersByProjectId(projectId)
},
searchProjects: async (
_: any,
{ searchText }: { searchText: string },
context: any
) => {
return service.searchProjects(context.user, searchText)
},
domains: async (
_: any,
{
projectId,
filter
}: { projectId: string; filter?: FindOptionsWhere<Domain> }
) => {
return service.getDomainsByProjectId(projectId, filter)
},
getAuctionData: async (_: any, { auctionId }: { auctionId: string }) => {
return service.getAuctionData(auctionId)
},
deployers: async (_: any, __: any) => {
return service.getDeployers()
},
address: async (_: any, __: any) => {
return service.getAddress()
},
verifyTx: async (
_: any,
{
txHash,
amount,
senderAddress
}: { txHash: string; amount: string; senderAddress: string }
) => {
return service.verifyTx(txHash, amount, senderAddress)
},
latestDNSRecord: async (_: any, { projectId }: { projectId: string }) => {
return service.getLatestDNSRecordByProjectId(projectId)
}
},
// TODO: Return error in GQL response
Mutation: {
removeProjectMember: async (
_: any,
{ projectMemberId }: { projectMemberId: string },
context: any
) => {
try {
return await service.removeProjectMember(
context.user,
projectMemberId
)
} catch (err) {
log(err)
return false
}
},
updateProjectMember: async (
_: any,
{
projectMemberId,
data
}: {
projectMemberId: string
data: {
permissions: Permission[]
}
}
) => {
try {
return await service.updateProjectMember(projectMemberId, data)
} catch (err) {
log(err)
return false
}
},
addProjectMember: async (
_: any,
{
projectId,
data
}: {
projectId: string
data: {
email: string
permissions: Permission[]
}
}
) => {
try {
return Boolean(await service.addProjectMember(projectId, data))
} catch (err) {
log(err)
return false
}
},
addEnvironmentVariables: async (
_: any,
{
projectId,
data
}: {
projectId: string
data: { environments: string[]; key: string; value: string }[]
}
) => {
try {
return Boolean(await service.addEnvironmentVariables(projectId, data))
} catch (err) {
log(err)
return false
}
},
updateEnvironmentVariable: async (
_: any,
{
environmentVariableId,
data
}: {
environmentVariableId: string
data: DeepPartial<EnvironmentVariable>
}
) => {
try {
return await service.updateEnvironmentVariable(
environmentVariableId,
data
)
} catch (err) {
log(err)
return false
}
},
removeEnvironmentVariable: async (
_: any,
{ environmentVariableId }: { environmentVariableId: string }
) => {
try {
return await service.removeEnvironmentVariable(environmentVariableId)
} catch (err) {
log(err)
return false
}
},
updateDeploymentToProd: async (
_: any,
{ deploymentId }: { deploymentId: string },
context: any
) => {
try {
return Boolean(
await service.updateDeploymentToProd(context.user, deploymentId)
)
} catch (err) {
log(err)
return false
}
},
addProjectFromTemplate: async (
_: any,
{
organizationSlug,
data,
lrn,
auctionParams,
environmentVariables
}: {
organizationSlug: string
data: AddProjectFromTemplateInput
lrn: string
auctionParams: AuctionParams
environmentVariables: EnvironmentVariables[]
},
context: any
) => {
try {
return await service.addProjectFromTemplate(
context.user,
organizationSlug,
data,
lrn,
auctionParams,
environmentVariables
)
} catch (err) {
log(err)
throw err
}
},
addProject: async (
_: any,
{
organizationSlug,
data,
lrn,
auctionParams,
environmentVariables
}: {
organizationSlug: string
data: DeepPartial<Project>
lrn: string
auctionParams: AuctionParams
environmentVariables: EnvironmentVariables[]
},
context: any
) => {
try {
return await service.addProject(
context.user,
organizationSlug,
data,
lrn,
auctionParams,
environmentVariables
)
} catch (err) {
log(err)
throw err
}
},
updateProject: async (
_: any,
{ projectId, data }: { projectId: string; data: DeepPartial<Project> }
) => {
try {
return await service.updateProject(projectId, data)
} catch (err) {
log(err)
return false
}
},
redeployToProd: async (
_: any,
{ deploymentId }: { deploymentId: string },
context: any
) => {
try {
return Boolean(
await service.redeployToProd(context.user, deploymentId)
)
} catch (err) {
log(err)
return false
}
},
deleteProject: async (_: any, { projectId }: { projectId: string }) => {
try {
return await service.deleteProject(projectId)
} catch (err) {
log(err)
return false
}
},
deleteDomain: async (_: any, { domainId }: { domainId: string }) => {
try {
return await service.deleteDomain(domainId)
} catch (err) {
log(err)
return false
}
},
rollbackDeployment: async (
_: any,
{ projectId, deploymentId }: { deploymentId: string; projectId: string }
) => {
try {
return await service.rollbackDeployment(projectId, deploymentId)
} catch (err) {
log(err)
return false
}
},
deleteDeployment: async (
_: any,
{ deploymentId }: { deploymentId: string }
) => {
try {
return await service.deleteDeployment(deploymentId)
} catch (err) {
log(err)
return false
}
},
addDomain: async (
_: any,
{ projectId, data }: { projectId: string; data: { name: string } }
) => {
try {
return Boolean(await service.addDomain(projectId, data))
} catch (err) {
log(err)
return false
}
},
updateDomain: async (
_: any,
{ domainId, data }: { domainId: string; data: DeepPartial<Domain> }
) => {
try {
return await service.updateDomain(domainId, data)
} catch (err) {
log(err)
return false
}
},
authenticateGitHub: async (
_: any,
{ code }: { code: string },
context: any
) => {
try {
return await service.authenticateGitHub(code, context.user)
} catch (err) {
log(err)
return false
}
},
unauthenticateGitHub: async (_: any, __: object, context: any) => {
try {
return service.unauthenticateGitHub(context.user, {
gitHubToken: null
})
} catch (err) {
log(err)
return false
}
}
}
}
}

View File

@ -1,97 +0,0 @@
import { Router } from 'express'
import { SiweMessage } from 'siwe'
import type { Service } from '../service'
import { authenticateUser, createUser } from '../turnkey-backend'
const router: Router = Router()
//
// Turnkey
//
router.get('/registration/:email', async (req, res) => {
const service: Service = req.app.get('service')
const user = await service.getUserByEmail(req.params.email)
if (user) {
return res.send({ subOrganizationId: user?.subOrgId })
}
return res.sendStatus(204)
})
router.post('/register', async (req, res) => {
console.log('Register', req.body)
const { email, challenge, attestation } = req.body
const user = await createUser(req.app.get('service'), {
challenge,
attestation,
userEmail: email,
userName: email.split('@')[0]
})
req.session.address = user.id
res.sendStatus(200)
})
router.post('/authenticate', async (req, res) => {
console.log('Authenticate', req.body)
const { signedWhoamiRequest } = req.body
const user = await authenticateUser(
req.app.get('service'),
signedWhoamiRequest
)
if (user) {
req.session.address = user.id
res.sendStatus(200)
} else {
res.sendStatus(401)
}
})
//
// SIWE Auth
//
router.post('/validate', async (req, res) => {
const { message, signature } = req.body
const { success, data } = await new SiweMessage(message).verify({
signature
})
if (!success) {
return res.send({ success })
}
const service: Service = req.app.get('service')
const user = await service.getUserByEthAddress(data.address)
if (!user) {
const newUser = await service.createUser({
ethAddress: data.address,
email: `${data.address}@example.com`,
subOrgId: '',
turnkeyWalletId: ''
})
// SIWESession from the web3modal library requires both address and chain ID
req.session.address = newUser.id
req.session.chainId = data.chainId
} else {
req.session.address = user.id
req.session.chainId = data.chainId
}
res.send({ success })
})
//
// General
//
router.get('/session', (req, res) => {
if (req.session.address && req.session.chainId) {
res.send({
address: req.session.address,
chainId: req.session.chainId
})
} else {
res.status(401).send({ error: 'Unauthorized: No active session' })
}
})
export default router

View File

@ -1,26 +0,0 @@
import debug from 'debug'
import { Router } from 'express'
import type { Service } from '../service'
const log = debug('snowball:routes-github')
const router: Router = Router()
/* POST GitHub webhook handler */
// https://docs.github.com/en/webhooks/using-webhooks/handling-webhook-deliveries#javascript-example
router.post('/webhook', async (req, res) => {
// Server should respond with a 2XX response within 10 seconds of receiving a webhook delivery
// If server takes longer than that to respond, then GitHub terminates the connection and considers the delivery a failure
res.status(202).send('Accepted')
const service = req.app.get('service') as Service
const githubEvent = req.headers['x-github-event']
log(`Received GitHub webhook for event ${githubEvent}`)
if (githubEvent === 'push') {
// Create deployments using push event data
await service.handleGitHubPush(req.body)
}
})
export default router

View File

@ -1,9 +0,0 @@
import { Router } from 'express'
const router: Router = Router()
router.get('/version', async (_req, res) => {
return res.send({ version: '0.0.9' })
})
export default router

View File

@ -1,337 +0,0 @@
enum Role {
Owner
Maintainer
Reader
}
enum Permission {
View
Edit
}
enum Environment {
Production
Preview
Development
}
enum DeploymentStatus {
Building
Ready
Error
Deleting
}
enum AuctionStatus {
completed
reveal
commit
expired
}
enum DomainStatus {
Live
Pending
}
type User {
id: String!
name: String
email: String!
organizations: [Organization!]
projects: [Project!]
isVerified: Boolean!
createdAt: String!
updatedAt: String!
gitHubToken: String
}
type Organization {
id: String!
name: String!
slug: String!
projects: [Project!]
createdAt: String!
updatedAt: String!
members: [OrganizationMember!]
}
type OrganizationMember {
id: String!
member: User!
role: Role!
createdAt: String!
updatedAt: String!
}
type Project {
id: String!
owner: User!
deployments: [Deployment!]
name: String!
repository: String!
prodBranch: String!
description: String
deployers: [Deployer!]
auctionId: String
fundsReleased: Boolean
template: String
framework: String
paymentAddress: String!
txHash: String!
webhooks: [String!]
members: [ProjectMember!]
environmentVariables: [EnvironmentVariable!]
createdAt: String!
updatedAt: String!
organization: Organization!
icon: String
baseDomains: [String!]
}
type ProjectMember {
id: String!
member: User!
permissions: [Permission!]!
isPending: Boolean!
createdAt: String!
updatedAt: String!
}
type Deployment {
id: String!
branch: String!
commitHash: String!
commitMessage: String!
url: String
environment: Environment!
deployer: Deployer
applicationDeploymentRequestId: String
applicationDeploymentRecordData: AppDeploymentRecordAttributes
isCurrent: Boolean!
baseDomain: String
status: DeploymentStatus!
createdAt: String!
updatedAt: String!
createdBy: User!
}
type Domain {
id: String!
branch: String!
name: String!
redirectTo: Domain
status: DomainStatus!
createdAt: String!
updatedAt: String!
}
type EnvironmentVariable {
id: String!
environment: Environment!
key: String!
value: String!
createdAt: String!
updatedAt: String!
}
type Deployer {
deployerLrn: String!
deployerId: String!
deployerApiUrl: String!
minimumPayment: String
paymentAddress: String
createdAt: String!
updatedAt: String!
baseDomain: String
}
type AuthResult {
token: String!
}
input AddEnvironmentVariableInput {
environments: [Environment!]!
key: String!
value: String!
}
input AddProjectFromTemplateInput {
templateOwner: String!
templateRepo: String!
owner: String!
name: String!
isPrivate: Boolean!
paymentAddress: String!
txHash: String!
}
input AddProjectInput {
name: String!
repository: String!
prodBranch: String!
template: String
paymentAddress: String!
txHash: String!
}
input UpdateProjectInput {
name: String
description: String
prodBranch: String
organizationId: String
webhooks: [String!]
}
input AddDomainInput {
name: String!
}
input UpdateDomainInput {
name: String
branch: String
redirectToId: String
}
input UpdateEnvironmentVariableInput {
key: String
value: String
}
input AddProjectMemberInput {
email: String!
permissions: [Permission!]
}
input UpdateProjectMemberInput {
permissions: [Permission]
}
input FilterDomainsInput {
branch: String
status: DomainStatus
}
type Fee {
type: String!
quantity: String!
}
type Bid {
auctionId: String!
bidderAddress: String!
status: String!
commitHash: String!
commitTime: String
commitFee: Fee
revealTime: String
revealFee: Fee
bidAmount: Fee
}
type Auction {
id: String!
kind: String!
status: String!
ownerAddress: String!
createTime: String!
commitsEndTime: String!
revealsEndTime: String!
commitFee: Fee!
revealFee: Fee!
minimumBid: Fee
winnerAddresses: [String!]!
winnerBids: [Fee!]
winnerPrice: Fee
maxPrice: Fee
numProviders: Int!
fundsReleased: Boolean!
bids: [Bid!]!
}
type DNSRecordAttributes {
name: String
value: String
request: String
resourceType: String
version: String
}
type AppDeploymentRecordAttributes {
application: String
auction: String
deployer: String
dns: String
meta: String
name: String
request: String
type: String
url: String
version: String
}
input AuctionParams {
maxPrice: String
numProviders: Int
}
type Query {
user: User!
organizations: [Organization!]
projects: [Project!]
projectsInOrganization(organizationSlug: String!): [Project!]
project(projectId: String!): Project
deployments(projectId: String!): [Deployment!]
environmentVariables(projectId: String!): [EnvironmentVariable!]
projectMembers(projectId: String!): [ProjectMember!]
searchProjects(searchText: String!): [Project!]
getAuctionData(auctionId: String!): Auction!
latestDNSRecord(projectId: String!): DNSRecordAttributes
domains(projectId: String!, filter: FilterDomainsInput): [Domain]
deployers: [Deployer]
address: String!
verifyTx(txHash: String!, amount: String!, senderAddress: String!): Boolean!
}
type Mutation {
addProjectMember(projectId: String!, data: AddProjectMemberInput): Boolean!
updateProjectMember(
projectMemberId: String!
data: UpdateProjectMemberInput
): Boolean!
removeProjectMember(projectMemberId: String!): Boolean!
addEnvironmentVariables(
projectId: String!
data: [AddEnvironmentVariableInput!]
): Boolean!
updateEnvironmentVariable(
environmentVariableId: String!
data: UpdateEnvironmentVariableInput!
): Boolean!
removeEnvironmentVariable(environmentVariableId: String!): Boolean!
updateDeploymentToProd(deploymentId: String!): Boolean!
addProjectFromTemplate(
organizationSlug: String!
data: AddProjectFromTemplateInput
lrn: String
auctionParams: AuctionParams
environmentVariables: [AddEnvironmentVariableInput!]
): Project!
addProject(
organizationSlug: String!
data: AddProjectInput!
lrn: String
auctionParams: AuctionParams
environmentVariables: [AddEnvironmentVariableInput!]
): Project!
updateProject(projectId: String!, data: UpdateProjectInput): Boolean!
redeployToProd(deploymentId: String!): Boolean!
deleteProject(projectId: String!): Boolean!
deleteDomain(domainId: String!): Boolean!
rollbackDeployment(projectId: String!, deploymentId: String!): Boolean!
deleteDeployment(deploymentId: String!): Boolean!
addDomain(projectId: String!, data: AddDomainInput!): Boolean!
updateDomain(domainId: String!, data: UpdateDomainInput!): Boolean!
authenticateGitHub(code: String!): AuthResult!
unauthenticateGitHub: Boolean!
}

View File

@ -1,130 +0,0 @@
import {
ApolloServerPluginDrainHttpServer,
ApolloServerPluginLandingPageLocalDefault,
AuthenticationError
} from 'apollo-server-core'
import { ApolloServer } from 'apollo-server-express'
import cors from 'cors'
import debug from 'debug'
import express from 'express'
import session from 'express-session'
import { createServer } from 'node:http'
import { makeExecutableSchema } from '@graphql-tools/schema'
import type { TypeSource } from '@graphql-tools/utils'
import type { ServerConfig } from './config'
import authRouter from './routes/auth'
import githubRouter from './routes/github'
import stagingRouter from './routes/staging'
import type { Service } from './service'
const log = debug('snowball:server')
// Set cookie expiration to 1 month in milliseconds
const COOKIE_MAX_AGE = 30 * 24 * 60 * 60 * 1000
declare module 'express-session' {
interface SessionData {
address: string
chainId: number
}
}
export const createAndStartServer = async (
serverConfig: ServerConfig,
typeDefs: TypeSource,
resolvers: any,
service: Service
): Promise<ApolloServer> => {
const { host, port, gqlPath = '/graphql' } = serverConfig
const { appOriginUrl, secret, domain, trustProxy } = serverConfig.session
const app = express()
// Create HTTP server
const httpServer = createServer(app)
// Create the schema
const schema = makeExecutableSchema({
typeDefs,
resolvers
})
const server = new ApolloServer({
schema,
csrfPrevention: true,
context: async ({ req }) => {
// https://www.apollographql.com/docs/apollo-server/v3/security/authentication#api-wide-authorization
const { address } = req.session
if (!address) {
throw new AuthenticationError('Unauthorized: No active session')
}
const user = await service.getUser(address)
return { user }
},
plugins: [
// Proper shutdown for the HTTP server
ApolloServerPluginDrainHttpServer({ httpServer }),
ApolloServerPluginLandingPageLocalDefault({ embed: true })
]
})
await server.start()
app.use(
cors({
origin: appOriginUrl,
credentials: true
})
)
const sessionOptions: session.SessionOptions = {
secret: secret,
resave: false,
saveUninitialized: true,
cookie: {
secure: new URL(appOriginUrl).protocol === 'https:',
maxAge: COOKIE_MAX_AGE,
domain: domain || undefined,
sameSite: new URL(appOriginUrl).protocol === 'https:' ? 'none' : 'lax'
}
}
if (trustProxy) {
// trust first proxy
app.set('trust proxy', 1)
}
app.use(session(sessionOptions))
server.applyMiddleware({
app: app as any,
path: gqlPath,
cors: {
origin: [appOriginUrl],
credentials: true
}
})
app.use(express.json())
app.set('service', service)
app.use('/auth', authRouter)
app.use('/api/github', githubRouter)
app.use('/staging', stagingRouter)
app.use((err: any, _req: any, res: any, _next: any) => {
console.error(err)
res.status(500).json({ error: err.message })
})
httpServer.listen(port, host, () => {
log(`Server is listening on ${host}:${port}${server.graphqlPath}`)
})
return server
}

File diff suppressed because it is too large Load Diff

View File

@ -1,130 +0,0 @@
import { Turnkey, type TurnkeyApiTypes } from '@turnkey/sdk-server'
// Default path for the first Ethereum address in a new HD wallet.
// See https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki, paths are in the form:
// m / purpose' / coin_type' / account' / change / address_index
// - Purpose is a constant set to 44' following the BIP43 recommendation.
// - Coin type is set to 60 (ETH) -- see https://github.com/satoshilabs/slips/blob/master/slip-0044.md
// - Account, Change, and Address Index are set to 0
import { DEFAULT_ETHEREUM_ACCOUNTS } from '@turnkey/sdk-server'
import type { Service } from './service'
import { getConfig } from './utils'
type TAttestation = TurnkeyApiTypes['v1Attestation']
type CreateUserParams = {
userName: string
userEmail: string
challenge: string
attestation: TAttestation
}
export async function createUser(
service: Service,
{ userName, userEmail, challenge, attestation }: CreateUserParams
) {
try {
if (await service.getUserByEmail(userEmail)) {
throw new Error(`User already exists: ${userEmail}`)
}
const config = await getConfig()
const turnkey = new Turnkey(config.turnkey)
const apiClient = turnkey.api()
const walletName = 'Default ETH Wallet'
const createSubOrgResponse = await apiClient.createSubOrganization({
subOrganizationName: `Default SubOrg for ${userEmail}`,
rootQuorumThreshold: 1,
rootUsers: [
{
userName,
userEmail,
apiKeys: [],
authenticators: [
{
authenticatorName: 'Passkey',
challenge,
attestation
}
]
}
],
wallet: {
walletName: walletName,
accounts: DEFAULT_ETHEREUM_ACCOUNTS
}
})
const subOrgId = refineNonNull(createSubOrgResponse.subOrganizationId)
const wallet = refineNonNull(createSubOrgResponse.wallet)
const result = {
id: wallet.walletId,
address: wallet.addresses[0],
subOrgId: subOrgId
}
console.log('Turnkey success', result)
const user = await service.createUser({
name: userName,
email: userEmail,
subOrgId,
ethAddress: wallet.addresses[0],
turnkeyWalletId: wallet.walletId
})
console.log('New user', user)
return user
} catch (e) {
console.error('Failed to create user:', e)
throw e
}
}
export async function authenticateUser(
service: Service,
signedWhoamiRequest: {
url: string
body: any
stamp: {
stampHeaderName: string
stampHeaderValue: string
}
}
) {
try {
const tkRes = await fetch(signedWhoamiRequest.url, {
method: 'POST',
body: signedWhoamiRequest.body,
headers: {
[signedWhoamiRequest.stamp.stampHeaderName]:
signedWhoamiRequest.stamp.stampHeaderValue
}
})
console.log('AUTH RESULT', tkRes.status)
if (tkRes.status !== 200) {
console.log(await tkRes.text())
return null
}
const orgId = (await tkRes.json()).organizationId
const user = await service.getUserBySubOrgId(orgId)
return user
} catch (e) {
console.error('Failed to authenticate:', e)
throw e
}
}
function refineNonNull<T>(
input: T | null | undefined,
errorMessage?: string
): T {
if (input == null) {
throw new Error(errorMessage ?? `Unexpected ${JSON.stringify(input)}`)
}
return input
}

View File

@ -1,124 +0,0 @@
export interface PackageJSON {
name: string
version: string
author?: string
description?: string
homepage?: string
license?: string
repository?: string
}
export interface GitRepositoryDetails {
id: number
name: string
full_name: string
visibility?: string
updated_at?: string | null
default_branch?: string
}
export interface GitPushEventPayload {
repository: GitRepositoryDetails
ref: string
head_commit: {
id: string
message: string
}
deleted: boolean
}
export interface AppDeploymentRecordAttributes {
application: string
auction: string
deployer: string
dns: string
meta: string
name: string
request: string
type: string
url: string
version: string
}
export interface DNSRecordAttributes {
name: string
value: string
request: string
resourceType: string
version: string
}
export interface RegistryDNSRecordAttributes {
name: string
value: string
request: string
resource_type: string
version: string
}
export interface AppDeploymentRemovalRecordAttributes {
deployment: string
request: string
type: 'ApplicationDeploymentRemovalRecord'
version: string
}
export interface RegistryRecord {
id: string
names: string[] | null
owners: string[]
bondId: string
createTime: string
expiryTime: string
}
export interface AppDeploymentRecord extends RegistryRecord {
attributes: AppDeploymentRecordAttributes
}
export interface AppDeploymentRemovalRecord extends RegistryRecord {
attributes: AppDeploymentRemovalRecordAttributes
}
export interface DNSRecord extends RegistryRecord {
attributes: RegistryDNSRecordAttributes
}
export interface AddProjectFromTemplateInput {
templateOwner: string
templateRepo: string
owner: string
name: string
isPrivate: boolean
paymentAddress: string
txHash: string
}
export interface AuctionParams {
maxPrice: string
numProviders: number
}
export interface EnvironmentVariables {
environments: string[]
key: string
value: string
}
export interface DeployerRecord {
id: string
names: string[]
owners: string[]
bondId: string
createTime: string
expiryTime: string
attributes: {
apiUrl: string
minimumPayment: string | null
name: string
paymentAddress: string
publicKey: string
type: string
version: string
}
}

View File

@ -1,160 +0,0 @@
import debug from 'debug'
import fs from 'fs-extra'
import assert from 'node:assert'
import path from 'node:path'
import type { Octokit } from 'octokit'
import toml from 'toml'
import type {
DataSource,
DeepPartial,
EntityTarget,
ObjectLiteral
} from 'typeorm'
import type { Config } from './config'
interface PackageJSON {
name: string
description?: string
homepage?: string
license?: string
author?: string | { [key: string]: unknown }
version?: string
[key: string]: unknown
}
const log = debug('snowball:utils')
export async function getConfig() {
return await _getConfig<Config>(
path.join(__dirname, '../environments/local.toml')
)
}
const _getConfig = async <ConfigType>(
configFile: string
): Promise<ConfigType> => {
const fileExists = await fs.pathExists(configFile)
if (!fileExists) {
throw new Error(`Config file not found: ${configFile}`)
}
const config = toml.parse(await fs.readFile(configFile, 'utf8'))
log('config', JSON.stringify(config, null, 2))
return config
}
export const checkFileExists = async (filePath: string): Promise<boolean> => {
try {
await fs.access(filePath, fs.constants.F_OK)
return true
} catch (err) {
log(err)
return false
}
}
export const getEntities = async (filePath: string): Promise<any> => {
const entitiesData = await fs.readFile(filePath, 'utf-8')
const entities = JSON.parse(entitiesData)
return entities
}
export const loadAndSaveData = async <Entity extends ObjectLiteral>(
entityType: EntityTarget<Entity>,
dataSource: DataSource,
entities: any,
relations?: any | undefined
): Promise<Entity[]> => {
const entityRepository = dataSource.getRepository(entityType)
const savedEntity: Entity[] = []
for (const entityData of entities) {
let entity = entityRepository.create(entityData as DeepPartial<Entity>)
if (relations) {
for (const field in relations) {
const valueIndex = `${field}Index`
entity = {
...entity,
[field]: relations[field][entityData[valueIndex]]
}
}
}
const dbEntity = await entityRepository.save(entity)
savedEntity.push(dbEntity)
}
return savedEntity
}
export const sleep = async (ms: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, ms))
export const getRepoDetails = async (
octokit: Octokit,
repository: string,
commitHash: string | undefined
): Promise<{
repo: string
packageJSON: PackageJSON
repoUrl: string
}> => {
const [owner, repo] = repository.split('/')
const { data: packageJSONData } = await octokit.rest.repos.getContent({
owner,
repo,
path: 'package.json',
ref: commitHash
})
if (!packageJSONData) {
throw new Error('Package.json file not found')
}
assert(!Array.isArray(packageJSONData) && packageJSONData.type === 'file')
const packageJSON: PackageJSON = JSON.parse(atob(packageJSONData.content))
assert(packageJSON.name, "name field doesn't exist in package.json")
const repoUrl = (
await octokit.rest.repos.get({
owner,
repo
})
).data.html_url
return {
repo,
packageJSON,
repoUrl
}
}
// Wrapper method for registry txs to retry once if 'account sequence mismatch' occurs
export const registryTransactionWithRetry = async (
txMethod: () => Promise<any>
): Promise<any> => {
try {
return await txMethod()
} catch (error: any) {
if (!error.message.includes('account sequence mismatch')) {
throw error
}
console.error(
'Transaction failed due to account sequence mismatch. Retrying...'
)
try {
return await txMethod()
} catch (retryError: any) {
throw new Error(
`Transaction failed again after retry: ${retryError.message}`
)
}
}
}

View File

@ -1,19 +0,0 @@
import * as fs from 'node:fs/promises'
import debug from 'debug'
import { getConfig } from '../src/utils'
const log = debug('snowball:delete-database')
const deleteFile = async (filePath: string) => {
await fs.unlink(filePath)
log(`File ${filePath} has been deleted.`)
}
const main = async () => {
const config = await getConfig()
deleteFile(config.database.dbPath)
}
main().catch((err) => log(err))

View File

@ -1,189 +0,0 @@
[
{
"projectIndex": 0,
"domainIndex": 0,
"createdByIndex": 0,
"id": "ffhae3zq",
"status": "Ready",
"environment": "Production",
"isCurrent": true,
"applicationRecordId": "qbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
"applicationRecordData": {},
"applicationDeploymentRequestId": "xqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
"applicationDeploymentRequestData": {},
"branch": "main",
"commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00",
"commitMessage": "subscription added",
"url": "testProject-ffhae3zq.snowball.xyz"
},
{
"projectIndex": 0,
"domainIndex": 1,
"createdByIndex": 0,
"id": "vehagei8",
"status": "Ready",
"environment": "Preview",
"isCurrent": false,
"applicationRecordId": "wbafyreihvzya6ovp4yfpkqnddkui2iw7thbhwq74lbqs7bhobvmfhrowoi",
"applicationRecordData": {},
"applicationDeploymentRequestId": "wqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
"applicationDeploymentRequestData": {},
"branch": "test",
"commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00",
"commitMessage": "subscription added",
"url": "testProject-vehagei8.snowball.xyz"
},
{
"projectIndex": 0,
"domainIndex": 2,
"createdByIndex": 0,
"id": "qmgekyte",
"status": "Ready",
"environment": "Development",
"isCurrent": false,
"applicationRecordId": "ebafyreihvzya6ovp4yfpkqnddkui2iw7t6bhwq74lbqs7bhobvmfhrowoi",
"applicationRecordData": {},
"applicationDeploymentRequestId": "kqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
"applicationDeploymentRequestData": {},
"branch": "test",
"commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00",
"commitMessage": "subscription added",
"url": "testProject-qmgekyte.snowball.xyz"
},
{
"projectIndex": 0,
"domainIndex": null,
"createdByIndex": 0,
"id": "f8wsyim6",
"status": "Ready",
"environment": "Production",
"isCurrent": false,
"applicationRecordId": "rbafyreihvzya6ovp4yfpkqnddkui2iw7t6hbhw74lbqs7bhobvmfhrowoi",
"applicationRecordData": {},
"applicationDeploymentRequestId": "yqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
"applicationDeploymentRequestData": {},
"branch": "prod",
"commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00",
"commitMessage": "subscription added",
"url": "testProject-f8wsyim6.snowball.xyz"
},
{
"projectIndex": 1,
"domainIndex": 3,
"createdByIndex": 1,
"id": "eO8cckxk",
"status": "Ready",
"environment": "Production",
"isCurrent": true,
"applicationRecordId": "tbafyreihvzya6ovp4yfpqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
"applicationRecordData": {},
"applicationDeploymentRequestId": "pqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
"applicationDeploymentRequestData": {},
"branch": "main",
"commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00",
"commitMessage": "subscription added",
"url": "testProject-2-eO8cckxk.snowball.xyz"
},
{
"projectIndex": 1,
"domainIndex": 4,
"createdByIndex": 1,
"id": "yaq0t5yw",
"status": "Ready",
"environment": "Preview",
"isCurrent": false,
"applicationRecordId": "ybafyreihvzya6ovp4yfpkqnddkui2iw7t6bhwq74lbqs7bhobvmfhrowoi",
"applicationRecordData": {},
"applicationDeploymentRequestId": "tqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
"applicationDeploymentRequestData": {},
"branch": "test",
"commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00",
"commitMessage": "subscription added",
"url": "testProject-2-yaq0t5yw.snowball.xyz"
},
{
"projectIndex": 1,
"domainIndex": 5,
"createdByIndex": 1,
"id": "hwwr6sbx",
"status": "Ready",
"environment": "Development",
"isCurrent": false,
"applicationRecordId": "ubafyreihvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvfhrowoi",
"applicationRecordData": {},
"applicationDeploymentRequestId": "eqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
"applicationDeploymentRequestData": {},
"branch": "test",
"commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00",
"commitMessage": "subscription added",
"url": "testProject-2-hwwr6sbx.snowball.xyz"
},
{
"projectIndex": 2,
"domainIndex": 9,
"createdByIndex": 2,
"id": "ndxje48a",
"status": "Ready",
"environment": "Production",
"isCurrent": true,
"applicationRecordId": "ibayreihvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
"applicationRecordData": {},
"applicationDeploymentRequestId": "dqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
"applicationDeploymentRequestData": {},
"branch": "main",
"commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00",
"commitMessage": "subscription added",
"url": "iglootools-ndxje48a.snowball.xyz"
},
{
"projectIndex": 2,
"domainIndex": 7,
"createdByIndex": 2,
"id": "gtgpgvei",
"status": "Ready",
"environment": "Preview",
"isCurrent": false,
"applicationRecordId": "obafyreihvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
"applicationRecordData": {},
"applicationDeploymentRequestId": "aqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
"applicationDeploymentRequestData": {},
"branch": "test",
"commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00",
"commitMessage": "subscription added",
"url": "iglootools-gtgpgvei.snowball.xyz"
},
{
"projectIndex": 2,
"domainIndex": 8,
"createdByIndex": 2,
"id": "b4bpthjr",
"status": "Ready",
"environment": "Development",
"isCurrent": false,
"applicationRecordId": "pbafyreihvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowo",
"applicationRecordData": {},
"applicationDeploymentRequestId": "uqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
"applicationDeploymentRequestData": {},
"branch": "test",
"commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00",
"commitMessage": "subscription added",
"url": "iglootools-b4bpthjr.snowball.xyz"
},
{
"projectIndex": 3,
"domainIndex": 6,
"createdByIndex": 2,
"id": "b4bpthjr",
"status": "Ready",
"environment": "Production",
"isCurrent": true,
"applicationRecordId": "pbafyreihvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowo",
"applicationRecordData": {},
"applicationDeploymentRequestId": "pqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
"applicationDeploymentRequestData": {},
"branch": "test",
"commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00",
"commitMessage": "subscription added",
"url": "iglootools-b4bpthjr.snowball.xyz"
}
]

View File

@ -1,92 +0,0 @@
[
{
"projectIndex": 0,
"key": "ABC",
"value": "ABC",
"environment": "Production"
},
{
"projectIndex": 0,
"key": "ABC",
"value": "ABC",
"environment": "Preview"
},
{
"projectIndex": 0,
"key": "XYZ",
"value": "abc3",
"environment": "Preview"
},
{
"projectIndex": 1,
"key": "ABC",
"value": "ABC",
"environment": "Production"
},
{
"projectIndex": 1,
"key": "ABC",
"value": "ABC",
"environment": "Preview"
},
{
"projectIndex": 1,
"key": "XYZ",
"value": "abc3",
"environment": "Preview"
},
{
"projectIndex": 2,
"key": "ABC",
"value": "ABC",
"environment": "Production"
},
{
"projectIndex": 2,
"key": "ABC",
"value": "ABC",
"environment": "Preview"
},
{
"projectIndex": 2,
"key": "XYZ",
"value": "abc3",
"environment": "Preview"
},
{
"projectIndex": 3,
"key": "ABC",
"value": "ABC",
"environment": "Production"
},
{
"projectIndex": 3,
"key": "ABC",
"value": "ABC",
"environment": "Preview"
},
{
"projectIndex": 3,
"key": "XYZ",
"value": "abc3",
"environment": "Preview"
},
{
"projectIndex": 4,
"key": "ABC",
"value": "ABC",
"environment": "Production"
},
{
"projectIndex": 4,
"key": "ABC",
"value": "ABC",
"environment": "Preview"
},
{
"projectIndex": 4,
"key": "XYZ",
"value": "abc3",
"environment": "Preview"
}
]

View File

@ -1,7 +0,0 @@
[
{
"id": "2379cf1f-a232-4ad2-ae14-4d881131cc26",
"name": "Deploy Tools",
"slug": "deploy-tools"
}
]

View File

@ -1,44 +0,0 @@
[
{
"projectIndex": 0,
"name": "example.snowballtools.xyz",
"status": "Live",
"branch": "main"
},
{
"projectIndex": 0,
"name": "example.org",
"status": "Pending",
"branch": "test"
},
{
"projectIndex": 1,
"name": "example.snowballtools.xyz",
"status": "Live",
"branch": "main"
},
{
"projectIndex": 1,
"name": "example.org",
"status": "Pending",
"branch": "test"
},
{
"projectIndex": 2,
"name": "example.snowballtools.xyz",
"status": "Live",
"branch": "main"
},
{
"projectIndex": 2,
"name": "example.org",
"status": "Pending",
"branch": "test"
},
{
"projectIndex": 3,
"name": "iglootools-2.com",
"status": "Pending",
"branch": "test"
}
]

View File

@ -1,56 +0,0 @@
[
{
"memberIndex": 1,
"projectIndex": 0,
"permissions": ["View"],
"isPending": false
},
{
"memberIndex": 2,
"projectIndex": 0,
"permissions": ["View", "Edit"],
"isPending": false
},
{
"memberIndex": 2,
"projectIndex": 1,
"permissions": ["View"],
"isPending": false
},
{
"memberIndex": 0,
"projectIndex": 2,
"permissions": ["View"],
"isPending": false
},
{
"memberIndex": 1,
"projectIndex": 2,
"permissions": ["View", "Edit"],
"isPending": false
},
{
"memberIndex": 0,
"projectIndex": 3,
"permissions": ["View"],
"isPending": false
},
{
"memberIndex": 2,
"projectIndex": 3,
"permissions": ["View", "Edit"],
"isPending": false
},
{
"memberIndex": 1,
"projectIndex": 4,
"permissions": ["View"],
"isPending": false
},
{
"memberIndex": 2,
"projectIndex": 4,
"permissions": ["View", "Edit"],
"isPending": false
}
]

View File

@ -1,67 +0,0 @@
[
{
"ownerIndex": 0,
"organizationIndex": 0,
"name": "testProject",
"repository": "snowball-tools/snowball-ts-framework-template",
"prodBranch": "main",
"description": "test",
"template": "webapp",
"framework": "test",
"webhooks": [],
"icon": "",
"subDomain": "testProject.snowball.xyz"
},
{
"ownerIndex": 1,
"organizationIndex": 0,
"name": "testProject-2",
"repository": "snowball-tools/snowball-ts-framework-template",
"prodBranch": "main",
"description": "test-2",
"template": "webapp",
"framework": "test-2",
"webhooks": [],
"icon": "",
"subDomain": "testProject-2.snowball.xyz"
},
{
"ownerIndex": 2,
"organizationIndex": 0,
"name": "iglootools",
"repository": "snowball-tools/snowball-ts-framework-template",
"prodBranch": "main",
"description": "test-3",
"template": "webapp",
"framework": "test-3",
"webhooks": [],
"icon": "",
"subDomain": "iglootools.snowball.xyz"
},
{
"ownerIndex": 1,
"organizationIndex": 0,
"name": "iglootools-2",
"repository": "snowball-tools/snowball-ts-framework-template",
"prodBranch": "main",
"description": "test-4",
"template": "webapp",
"framework": "test-4",
"webhooks": [],
"icon": "",
"subDomain": "iglootools-2.snowball.xyz"
},
{
"ownerIndex": 0,
"organizationIndex": 1,
"name": "snowball-2",
"repository": "snowball-tools/snowball-ts-framework-template",
"prodBranch": "main",
"description": "test-5",
"template": "webapp",
"framework": "test-5",
"webhooks": [],
"icon": "",
"subDomain": "snowball-2.snowball.xyz"
}
]

View File

@ -1,23 +0,0 @@
[
{
"projectIndex": 0,
"name": "www.example.org",
"status": "Pending",
"redirectToIndex": 1,
"branch": "test"
},
{
"projectIndex": 1,
"name": "www.example.org",
"status": "Pending",
"redirectToIndex": 3,
"branch": "test"
},
{
"projectIndex": 2,
"name": "www.example.org",
"status": "Pending",
"redirectToIndex": 5,
"branch": "test"
}
]

View File

@ -1,22 +0,0 @@
[
{
"role": "Owner",
"memberIndex": 0,
"organizationIndex": 0
},
{
"role": "Maintainer",
"memberIndex": 1,
"organizationIndex": 0
},
{
"role": "Owner",
"memberIndex": 2,
"organizationIndex": 0
},
{
"role": "Owner",
"memberIndex": 0,
"organizationIndex": 1
}
]

View File

@ -1,23 +0,0 @@
[
{
"id": "59f4355d-9549-4aac-9b54-eeefceeabef0",
"name": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2",
"email": "snowball@snowballtools.xyz",
"isVerified": true,
"ethAddress": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2"
},
{
"id": "e505b212-8da6-48b2-9614-098225dab34b",
"name": "0xbe0eb53f46cd790cd13851d5eff43d12404d33e8",
"email": "alice@snowballtools.xyz",
"isVerified": true,
"ethAddress": "0xbe0eb53f46cd790cd13851d5eff43d12404d33e8"
},
{
"id": "cd892fad-9138-4aa2-a62c-414a32776ea7",
"name": "0x8315177ab297ba92a06054ce80a67ed4dbd7ed3a",
"email": "bob@snowballtools.xyz",
"isVerified": true,
"ethAddress": "0x8315177ab297ba92a06054ce80a67ed4dbd7ed3a"
}
]

View File

@ -1,176 +0,0 @@
import path from 'node:path'
import debug from 'debug'
import { DataSource } from 'typeorm'
import { Deployment } from '../src/entity/Deployment'
import { Domain } from '../src/entity/Domain'
import { EnvironmentVariable } from '../src/entity/EnvironmentVariable'
import { Organization } from '../src/entity/Organization'
import { Project } from '../src/entity/Project'
import { ProjectMember } from '../src/entity/ProjectMember'
import { User } from '../src/entity/User'
import { UserOrganization } from '../src/entity/UserOrganization'
import {
checkFileExists,
getConfig,
getEntities,
loadAndSaveData
} from '../src/utils'
const log = debug('snowball:initialize-database')
const USER_DATA_PATH = './fixtures/users.json'
const PROJECT_DATA_PATH = './fixtures/projects.json'
const ORGANIZATION_DATA_PATH = './fixtures/organizations.json'
const USER_ORGANIZATION_DATA_PATH = './fixtures/user-organizations.json'
const PROJECT_MEMBER_DATA_PATH = './fixtures/project-members.json'
const PRIMARY_DOMAIN_DATA_PATH = './fixtures/primary-domains.json'
const DEPLOYMENT_DATA_PATH = './fixtures/deployments.json'
const ENVIRONMENT_VARIABLE_DATA_PATH = './fixtures/environment-variables.json'
const REDIRECTED_DOMAIN_DATA_PATH = './fixtures/redirected-domains.json'
const generateTestData = async (dataSource: DataSource) => {
const userEntities = await getEntities(
path.resolve(__dirname, USER_DATA_PATH)
)
const savedUsers = await loadAndSaveData(User, dataSource, userEntities)
const orgEntities = await getEntities(
path.resolve(__dirname, ORGANIZATION_DATA_PATH)
)
const savedOrgs = await loadAndSaveData(Organization, dataSource, orgEntities)
const projectRelations = {
owner: savedUsers,
organization: savedOrgs
}
const projectEntities = await getEntities(
path.resolve(__dirname, PROJECT_DATA_PATH)
)
const savedProjects = await loadAndSaveData(
Project,
dataSource,
projectEntities,
projectRelations
)
const domainRepository = dataSource.getRepository(Domain)
const domainPrimaryRelations = {
project: savedProjects
}
const primaryDomainsEntities = await getEntities(
path.resolve(__dirname, PRIMARY_DOMAIN_DATA_PATH)
)
const savedPrimaryDomains = await loadAndSaveData(
Domain,
dataSource,
primaryDomainsEntities,
domainPrimaryRelations
)
const domainRedirectedRelations = {
project: savedProjects,
redirectTo: savedPrimaryDomains
}
const redirectDomainsEntities = await getEntities(
path.resolve(__dirname, REDIRECTED_DOMAIN_DATA_PATH)
)
await loadAndSaveData(
Domain,
dataSource,
redirectDomainsEntities,
domainRedirectedRelations
)
const savedDomains = await domainRepository.find()
const userOrganizationRelations = {
member: savedUsers,
organization: savedOrgs
}
const userOrganizationsEntities = await getEntities(
path.resolve(__dirname, USER_ORGANIZATION_DATA_PATH)
)
await loadAndSaveData(
UserOrganization,
dataSource,
userOrganizationsEntities,
userOrganizationRelations
)
const projectMemberRelations = {
member: savedUsers,
project: savedProjects
}
const projectMembersEntities = await getEntities(
path.resolve(__dirname, PROJECT_MEMBER_DATA_PATH)
)
await loadAndSaveData(
ProjectMember,
dataSource,
projectMembersEntities,
projectMemberRelations
)
const deploymentRelations = {
project: savedProjects,
domain: savedDomains,
createdBy: savedUsers
}
const deploymentsEntities = await getEntities(
path.resolve(__dirname, DEPLOYMENT_DATA_PATH)
)
await loadAndSaveData(
Deployment,
dataSource,
deploymentsEntities,
deploymentRelations
)
const environmentVariableRelations = {
project: savedProjects
}
const environmentVariablesEntities = await getEntities(
path.resolve(__dirname, ENVIRONMENT_VARIABLE_DATA_PATH)
)
await loadAndSaveData(
EnvironmentVariable,
dataSource,
environmentVariablesEntities,
environmentVariableRelations
)
}
const main = async () => {
const config = await getConfig()
const isDbPresent = await checkFileExists(config.database.dbPath)
if (!isDbPresent) {
const dataSource = new DataSource({
type: 'better-sqlite3',
database: config.database.dbPath,
synchronize: true,
logging: true,
entities: [path.join(__dirname, '../src/entity/*')]
})
await dataSource.initialize()
await generateTestData(dataSource)
log('Data loaded successfully')
} else {
log('WARNING: Database already exists')
}
}
main().catch((err) => {
log(err)
})

View File

@ -1,49 +0,0 @@
import debug from 'debug'
import { Registry, parseGasAndFees } from '@cerc-io/registry-sdk'
import { getConfig } from '../src/utils'
const log = debug('snowball:initialize-registry')
const DENOM = 'alnt'
const BOND_AMOUNT = '1000000000'
async function main() {
const { registryConfig } = await getConfig()
// TODO: Get authority names from args
const authorityNames = ['snowballtools', registryConfig.authority]
const registry = new Registry(
registryConfig.gqlEndpoint,
registryConfig.restEndpoint,
{ chainId: registryConfig.chainId }
)
const bondId = await registry.getNextBondId(registryConfig.privateKey)
log('bondId:', bondId)
const fee = parseGasAndFees(registryConfig.fee.gas, registryConfig.fee.fees)
await registry.createBond(
{ denom: DENOM, amount: BOND_AMOUNT },
registryConfig.privateKey,
fee
)
for await (const name of authorityNames) {
await registry.reserveAuthority({ name }, registryConfig.privateKey, fee)
log('Reserved authority name:', name)
await registry.setAuthorityBond(
{ name, bondId },
registryConfig.privateKey,
fee
)
log(`Bond ${bondId} set for authority ${name}`)
}
}
main().catch((err) => {
log(err)
})

View File

@ -1,100 +0,0 @@
import path from 'node:path'
import debug from 'debug'
import { DataSource } from 'typeorm'
import { Registry, parseGasAndFees } from '@cerc-io/registry-sdk'
import {
Deployment,
DeploymentStatus,
Environment
} from '../src/entity/Deployment'
import { getConfig } from '../src/utils'
const log = debug('snowball:publish-deploy-records')
async function main() {
const { registryConfig, database } = await getConfig()
const registry = new Registry(
registryConfig.gqlEndpoint,
registryConfig.restEndpoint,
{ chainId: registryConfig.chainId }
)
const dataSource = new DataSource({
type: 'better-sqlite3',
database: database.dbPath,
synchronize: true,
entities: [path.join(__dirname, '../src/entity/*')]
})
await dataSource.initialize()
const deploymentRepository = dataSource.getRepository(Deployment)
const deployments = await deploymentRepository.find({
relations: {
project: true
},
where: {
status: DeploymentStatus.Building
}
})
for await (const deployment of deployments) {
const url = `https://${(deployment.project.name).toLowerCase()}-${deployment.id}.${deployment.deployer.baseDomain}`
const applicationDeploymentRecord = {
type: 'ApplicationDeploymentRecord',
version: '0.0.1',
name: deployment.applicationRecordData.name,
application: deployment.applicationRecordId,
// TODO: Create DNS record
dns: 'bafyreihlymqggsgqiqawvehkpr2imt4l3u6q7um7xzjrux5rhsvwnuyewm',
// Using dummy values
meta: JSON.stringify({
config: 'da39a3ee5e6b4b0d3255bfef95601890afd80709',
so: '66fcfa49a1664d4cb4ce4f72c1c0e151'
}),
request: deployment.applicationDeploymentRequestId,
url
}
const fee = parseGasAndFees(registryConfig.fee.gas, registryConfig.fee.fees)
const result = await registry.setRecord(
{
privateKey: registryConfig.privateKey,
record: applicationDeploymentRecord,
bondId: registryConfig.bondId
},
'',
fee
)
// Remove deployment for project subdomain if deployment is for production environment
if (deployment.environment === Environment.Production) {
applicationDeploymentRecord.url = `https://${deployment.project.name}.${deployment.deployer.baseDomain}`
await registry.setRecord(
{
privateKey: registryConfig.privateKey,
record: applicationDeploymentRecord,
bondId: registryConfig.bondId
},
'',
fee
)
}
log('Application deployment record data:', applicationDeploymentRecord)
log(`Application deployment record published: ${result.id}`)
}
}
main().catch((err) => {
log(err)
})

View File

@ -1,70 +0,0 @@
import path from 'node:path'
import debug from 'debug'
import { DataSource } from 'typeorm'
import { Registry, parseGasAndFees } from '@cerc-io/registry-sdk'
import { Deployment, DeploymentStatus } from '../src/entity/Deployment'
import { getConfig } from '../src/utils'
const log = debug('snowball:publish-deployment-removal-records')
async function main() {
const { registryConfig, database } = await getConfig()
const registry = new Registry(
registryConfig.gqlEndpoint,
registryConfig.restEndpoint,
{ chainId: registryConfig.chainId }
)
const dataSource = new DataSource({
type: 'better-sqlite3',
database: database.dbPath,
synchronize: true,
entities: [path.join(__dirname, '../src/entity/*')]
})
await dataSource.initialize()
const deploymentRepository = dataSource.getRepository(Deployment)
const deployments = await deploymentRepository.find({
relations: {
project: true
},
where: {
status: DeploymentStatus.Deleting
}
})
for await (const deployment of deployments) {
const applicationDeploymentRemovalRecord = {
type: 'ApplicationDeploymentRemovalRecord',
version: '1.0.0',
deployment: deployment.applicationDeploymentRecordId,
request: deployment.applicationDeploymentRemovalRequestId
}
const fee = parseGasAndFees(registryConfig.fee.gas, registryConfig.fee.fees)
const result = await registry.setRecord(
{
privateKey: registryConfig.privateKey,
record: applicationDeploymentRemovalRecord,
bondId: registryConfig.bondId
},
'',
fee
)
log(
'Application deployment removal record data:',
applicationDeploymentRemovalRecord
)
log(`Application deployment removal record published: ${result.id}`)
}
}
main().catch((err) => {
log(err)
})

View File

@ -1,13 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"baseUrl": ".",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["dist", "src/**/*.test.ts"]
}

View File

@ -6,6 +6,8 @@
"scripts": { "scripts": {
"dev": "NODE_OPTIONS='--inspect' next dev --turbopack", "dev": "NODE_OPTIONS='--inspect' next dev --turbopack",
"build": "next build", "build": "next build",
"build:compile": "next build --experimental-build-mode compile",
"build:generate": "next build --experimental-build-mode generate",
"start": "next start", "start": "next start",
"lint": "biome check .", "lint": "biome check .",
"lint:fix": "biome check --write .", "lint:fix": "biome check --write .",

View File

@ -1,15 +1,15 @@
'use client' 'use client'
import { useEffect, useState } from 'react' import { LaconicMark } from '@/components/assets/laconic-mark'
import { useRouter } from 'next/navigation'
import { X } from 'lucide-react'
import { useTheme } from 'next-themes'
import { useOnboarding } from '@/components/onboarding/store'
import { ConnectStep } from '@/components/onboarding/connect-step/connect-step'
import { ConfigureStep } from '@/components/onboarding/configure-step/configure-step' import { ConfigureStep } from '@/components/onboarding/configure-step/configure-step'
import { ConnectStep } from '@/components/onboarding/connect-step/connect-step'
import { DeployStep } from '@/components/onboarding/deploy-step/deploy-step' import { DeployStep } from '@/components/onboarding/deploy-step/deploy-step'
import { SuccessStep } from '@/components/onboarding/success-step/success-step' import { SuccessStep } from '@/components/onboarding/success-step/success-step'
import { LaconicMark } from '@/components/assets/laconic-mark' import { useOnboarding } from '@/components/onboarding/useOnboarding'
import { X } from 'lucide-react'
import { useTheme } from 'next-themes'
import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
/** /**
* Parent component for the onboarding flow * Parent component for the onboarding flow
@ -55,13 +55,21 @@ export default function CreateProjectFlow() {
return ( return (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
{/* Fixed dimensions modal container */} {/* Fixed dimensions modal container */}
<div className={`${isDarkMode ? 'bg-black' : 'bg-white'} rounded-xl overflow-hidden flex shadow-2xl w-[1000px] h-[620px]`}> <div
className={`${isDarkMode ? 'bg-black' : 'bg-white'} rounded-xl overflow-hidden flex shadow-2xl w-[1000px] h-[620px]`}
>
{/* Left sidebar with fixed width */} {/* Left sidebar with fixed width */}
<div className={`w-[280px] min-w-[280px] ${isDarkMode ? 'bg-zinc-900' : 'bg-zinc-50'} p-8 relative overflow-hidden border-r ${isDarkMode ? 'border-zinc-800' : 'border-zinc-200'}`}> <div
className={`w-[280px] min-w-[280px] ${isDarkMode ? 'bg-zinc-900' : 'bg-zinc-50'} p-8 relative overflow-hidden border-r ${isDarkMode ? 'border-zinc-800' : 'border-zinc-200'}`}
>
{/* Laconic logo */} {/* Laconic logo */}
<div className="flex items-center gap-2 mb-12"> <div className="flex items-center gap-2 mb-12">
<LaconicMark className="h-8 w-8" /> <LaconicMark className="h-8 w-8" />
<span className={`${isDarkMode ? 'text-white' : 'text-zinc-900'} text-xl font-bold`}>LACONIC</span> <span
className={`${isDarkMode ? 'text-white' : 'text-zinc-900'} text-xl font-bold`}
>
LACONIC
</span>
</div> </div>
{/* Steps - clickable */} {/* Steps - clickable */}
@ -72,29 +80,71 @@ export default function CreateProjectFlow() {
onClick={() => navigateToStep('connect')} onClick={() => navigateToStep('connect')}
> >
<div className="mr-4"> <div className="mr-4">
<div className={`w-10 h-10 rounded-lg ${currentStep === 'connect' <div
? (isDarkMode ? 'bg-white' : 'bg-black') className={`w-10 h-10 rounded-lg ${
: (isDarkMode ? 'bg-zinc-800' : 'bg-zinc-200') currentStep === 'connect'
} flex items-center justify-center`}> ? isDarkMode
<svg viewBox="0 0 24 24" width="20" height="20" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round" className={currentStep === 'connect' ? 'bg-white'
? (isDarkMode ? 'text-black' : 'text-white') : 'bg-black'
: (isDarkMode ? 'text-zinc-400' : 'text-zinc-600') : isDarkMode
}> ? 'bg-zinc-800'
: 'bg-zinc-200'
} flex items-center justify-center`}
>
<svg
viewBox="0 0 24 24"
width="20"
height="20"
stroke="currentColor"
strokeWidth="2"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
className={
currentStep === 'connect'
? isDarkMode
? 'text-black'
: 'text-white'
: isDarkMode
? 'text-zinc-400'
: 'text-zinc-600'
}
>
<path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path> <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
<line x1="8" y1="12" x2="16" y2="12"></line> <line x1="8" y1="12" x2="16" y2="12"></line>
</svg> </svg>
</div> </div>
<div className={`w-px h-10 ${isDarkMode ? 'bg-zinc-800' : 'bg-zinc-200'} mx-auto mt-2`}></div> <div
className={`w-px h-10 ${isDarkMode ? 'bg-zinc-800' : 'bg-zinc-200'} mx-auto mt-2`}
></div>
</div> </div>
<div> <div>
<h3 className={`font-medium text-base ${currentStep === 'connect' <h3
? (isDarkMode ? 'text-white' : 'text-zinc-900') className={`font-medium text-base ${
: (isDarkMode ? 'text-zinc-400' : 'text-zinc-600') currentStep === 'connect'
}`}>Connect</h3> ? isDarkMode
<p className={`text-sm ${currentStep === 'connect' ? 'text-white'
? (isDarkMode ? 'text-zinc-300' : 'text-zinc-700') : 'text-zinc-900'
: (isDarkMode ? 'text-zinc-500' : 'text-zinc-500') : isDarkMode
}`}>Connect and import a GitHub repo</p> ? 'text-zinc-400'
: 'text-zinc-600'
}`}
>
Connect
</h3>
<p
className={`text-sm ${
currentStep === 'connect'
? isDarkMode
? 'text-zinc-300'
: 'text-zinc-700'
: isDarkMode
? 'text-zinc-500'
: 'text-zinc-500'
}`}
>
Connect and import a GitHub repo
</p>
</div> </div>
</button> </button>
@ -104,29 +154,71 @@ export default function CreateProjectFlow() {
onClick={() => navigateToStep('configure')} onClick={() => navigateToStep('configure')}
> >
<div className="mr-4"> <div className="mr-4">
<div className={`w-10 h-10 rounded-lg ${currentStep === 'configure' <div
? (isDarkMode ? 'bg-white' : 'bg-black') className={`w-10 h-10 rounded-lg ${
: (isDarkMode ? 'bg-zinc-800' : 'bg-zinc-200') currentStep === 'configure'
} flex items-center justify-center`}> ? isDarkMode
<svg viewBox="0 0 24 24" width="20" height="20" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round" className={currentStep === 'configure' ? 'bg-white'
? (isDarkMode ? 'text-black' : 'text-white') : 'bg-black'
: (isDarkMode ? 'text-zinc-400' : 'text-zinc-600') : isDarkMode
}> ? 'bg-zinc-800'
: 'bg-zinc-200'
} flex items-center justify-center`}
>
<svg
viewBox="0 0 24 24"
width="20"
height="20"
stroke="currentColor"
strokeWidth="2"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
className={
currentStep === 'configure'
? isDarkMode
? 'text-black'
: 'text-white'
: isDarkMode
? 'text-zinc-400'
: 'text-zinc-600'
}
>
<path d="M12 20h9"></path> <path d="M12 20h9"></path>
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path> <path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path>
</svg> </svg>
</div> </div>
<div className={`w-px h-10 ${isDarkMode ? 'bg-zinc-800' : 'bg-zinc-200'} mx-auto mt-2`}></div> <div
className={`w-px h-10 ${isDarkMode ? 'bg-zinc-800' : 'bg-zinc-200'} mx-auto mt-2`}
></div>
</div> </div>
<div> <div>
<h3 className={`font-medium text-base ${currentStep === 'configure' <h3
? (isDarkMode ? 'text-white' : 'text-zinc-900') className={`font-medium text-base ${
: (isDarkMode ? 'text-zinc-400' : 'text-zinc-600') currentStep === 'configure'
}`}>Configure</h3> ? isDarkMode
<p className={`text-sm ${currentStep === 'configure' ? 'text-white'
? (isDarkMode ? 'text-zinc-300' : 'text-zinc-700') : 'text-zinc-900'
: (isDarkMode ? 'text-zinc-500' : 'text-zinc-500') : isDarkMode
}`}>Define the deployment type</p> ? 'text-zinc-400'
: 'text-zinc-600'
}`}
>
Configure
</h3>
<p
className={`text-sm ${
currentStep === 'configure'
? isDarkMode
? 'text-zinc-300'
: 'text-zinc-700'
: isDarkMode
? 'text-zinc-500'
: 'text-zinc-500'
}`}
>
Define the deployment type
</p>
</div> </div>
</button> </button>
@ -136,14 +228,36 @@ export default function CreateProjectFlow() {
onClick={() => navigateToStep('deploy')} onClick={() => navigateToStep('deploy')}
> >
<div className="mr-4"> <div className="mr-4">
<div className={`w-10 h-10 rounded-lg ${currentStep === 'deploy' || currentStep === 'success' <div
? (isDarkMode ? 'bg-white' : 'bg-black') className={`w-10 h-10 rounded-lg ${
: (isDarkMode ? 'bg-zinc-800' : 'bg-zinc-200') currentStep === 'deploy' || currentStep === 'success'
} flex items-center justify-center`}> ? isDarkMode
<svg viewBox="0 0 24 24" width="20" height="20" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round" className={currentStep === 'deploy' || currentStep === 'success' ? 'bg-white'
? (isDarkMode ? 'text-black' : 'text-white') : 'bg-black'
: (isDarkMode ? 'text-zinc-400' : 'text-zinc-600') : isDarkMode
}> ? 'bg-zinc-800'
: 'bg-zinc-200'
} flex items-center justify-center`}
>
<svg
viewBox="0 0 24 24"
width="20"
height="20"
stroke="currentColor"
strokeWidth="2"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
className={
currentStep === 'deploy' || currentStep === 'success'
? isDarkMode
? 'text-black'
: 'text-white'
: isDarkMode
? 'text-zinc-400'
: 'text-zinc-600'
}
>
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path> <path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path>
<polyline points="7.5 4.21 12 6.81 16.5 4.21"></polyline> <polyline points="7.5 4.21 12 6.81 16.5 4.21"></polyline>
<polyline points="7.5 19.79 7.5 14.6 3 12"></polyline> <polyline points="7.5 19.79 7.5 14.6 3 12"></polyline>
@ -154,26 +268,48 @@ export default function CreateProjectFlow() {
</div> </div>
</div> </div>
<div> <div>
<h3 className={`font-medium text-base ${currentStep === 'deploy' || currentStep === 'success' <h3
? (isDarkMode ? 'text-white' : 'text-zinc-900') className={`font-medium text-base ${
: (isDarkMode ? 'text-zinc-400' : 'text-zinc-600') currentStep === 'deploy' || currentStep === 'success'
}`}>Deploy</h3> ? isDarkMode
<p className={`text-sm ${currentStep === 'deploy' || currentStep === 'success' ? 'text-white'
? (isDarkMode ? 'text-zinc-300' : 'text-zinc-700') : 'text-zinc-900'
: (isDarkMode ? 'text-zinc-500' : 'text-zinc-500') : isDarkMode
}`}>Review and confirm deployment</p> ? 'text-zinc-400'
: 'text-zinc-600'
}`}
>
Deploy
</h3>
<p
className={`text-sm ${
currentStep === 'deploy' || currentStep === 'success'
? isDarkMode
? 'text-zinc-300'
: 'text-zinc-700'
: isDarkMode
? 'text-zinc-500'
: 'text-zinc-500'
}`}
>
Review and confirm deployment
</p>
</div> </div>
</button> </button>
</div> </div>
{/* Laconic mark (larger, bottom left) */} {/* Laconic mark (larger, bottom left) */}
<div className="absolute -bottom-2 -left-2 opacity-10"> <div className="absolute -bottom-2 -left-2 opacity-10">
<LaconicMark className={`w-40 h-40 ${isDarkMode ? 'text-zinc-300' : 'text-zinc-700'}`} /> <LaconicMark
className={`w-40 h-40 ${isDarkMode ? 'text-zinc-300' : 'text-zinc-700'}`}
/>
</div> </div>
</div> </div>
{/* Main content with fixed dimensions and scrolling */} {/* Main content with fixed dimensions and scrolling */}
<div className={`flex-1 ${isDarkMode ? 'bg-black' : 'bg-white'} relative`}> <div
className={`flex-1 ${isDarkMode ? 'bg-black' : 'bg-white'} relative`}
>
{/* Close button */} {/* Close button */}
<button <button
className={`absolute top-4 right-4 ${isDarkMode ? 'text-zinc-400 hover:text-white' : 'text-zinc-600 hover:text-zinc-900'} z-10`} className={`absolute top-4 right-4 ${isDarkMode ? 'text-zinc-400 hover:text-white' : 'text-zinc-600 hover:text-zinc-900'} z-10`}
@ -192,9 +328,15 @@ export default function CreateProjectFlow() {
{/* Progress indicator */} {/* Progress indicator */}
<div className="absolute bottom-6 left-0 right-0 flex justify-center gap-3"> <div className="absolute bottom-6 left-0 right-0 flex justify-center gap-3">
<div className={`w-12 h-1 rounded-full ${currentStep === 'connect' ? 'bg-blue-600' : (isDarkMode ? 'bg-zinc-700' : 'bg-zinc-300')}`}></div> <div
<div className={`w-12 h-1 rounded-full ${currentStep === 'configure' ? 'bg-blue-600' : (isDarkMode ? 'bg-zinc-700' : 'bg-zinc-300')}`}></div> className={`w-12 h-1 rounded-full ${currentStep === 'connect' ? 'bg-blue-600' : isDarkMode ? 'bg-zinc-700' : 'bg-zinc-300'}`}
<div className={`w-12 h-1 rounded-full ${currentStep === 'deploy' || currentStep === 'success' ? 'bg-blue-600' : (isDarkMode ? 'bg-zinc-700' : 'bg-zinc-300')}`}></div> ></div>
<div
className={`w-12 h-1 rounded-full ${currentStep === 'configure' ? 'bg-blue-600' : isDarkMode ? 'bg-zinc-700' : 'bg-zinc-300'}`}
></div>
<div
className={`w-12 h-1 rounded-full ${currentStep === 'deploy' || currentStep === 'success' ? 'bg-blue-600' : isDarkMode ? 'bg-zinc-700' : 'bg-zinc-300'}`}
></div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,10 +1,10 @@
import { Providers } from '@/components/providers' import { Providers } from '@/components/providers'
import { ClerkProvider } from '@clerk/nextjs' import { ClerkProvider } from '@clerk/nextjs'
import '@workspace/ui/globals.css' import '@workspace/ui/globals.css'
import { AutoSignInIFrameModal } from '@/components/iframe/auto-sign-in'
import { CheckBalanceWrapper } from '@/components/iframe/check-balance-iframe/CheckBalanceWrapper'
import type { Metadata } from 'next' import type { Metadata } from 'next'
import { Inter } from 'next/font/google' import { Inter } from 'next/font/google'
import { CheckBalanceWrapper } from '@/components/iframe/check-balance-iframe/CheckBalanceWrapper'
import { AutoSignInIFrameModal } from '@/components/iframe/auto-sign-in'
// Add root metadata with template pattern // Add root metadata with template pattern
export const metadata: Metadata = { export const metadata: Metadata = {
@ -25,7 +25,10 @@ export default function RootLayout({
children children
}: Readonly<{ children: React.ReactNode }>) { }: Readonly<{ children: React.ReactNode }>) {
return ( return (
<ClerkProvider signInFallbackRedirectUrl="/home"> <ClerkProvider
publishableKey={process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY}
signInFallbackRedirectUrl="/home"
>
<html lang="en" suppressHydrationWarning> <html lang="en" suppressHydrationWarning>
<body className={`${inter.className} `} suppressHydrationWarning> <body className={`${inter.className} `} suppressHydrationWarning>
<main> <main>

View File

@ -27,6 +27,8 @@ declare global {
} }
} }
const BACKEND_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'
export default function TestConnectionPage() { export default function TestConnectionPage() {
// Get getToken from useAuth hook, not from user // Get getToken from useAuth hook, not from user
const { isSignedIn, isLoaded: isClerkLoaded, getToken } = useAuth() const { isSignedIn, isLoaded: isClerkLoaded, getToken } = useAuth()
@ -136,7 +138,7 @@ export default function TestConnectionPage() {
const checkBackendConnection = async () => { const checkBackendConnection = async () => {
try { try {
// Test session // Test session
const response = await fetch('http://localhost:8000/auth/session', { const response = await fetch(`${BACKEND_URL}/auth/session`, {
method: 'GET', method: 'GET',
credentials: 'include', credentials: 'include',
}) })
@ -192,7 +194,7 @@ export default function TestConnectionPage() {
const checkWalletConnection = async () => { const checkWalletConnection = async () => {
if (isBackendConnected) { if (isBackendConnected) {
try { try {
const response = await fetch('http://localhost:8000/auth/session', { const response = await fetch(`${BACKEND_URL}/auth/session`, {
method: 'GET', method: 'GET',
credentials: 'include', credentials: 'include',
}) })

View File

@ -6,6 +6,8 @@ import { Button } from '@workspace/ui/components/button'
import { CheckBalanceWrapper } from './iframe/check-balance-iframe/CheckBalanceWrapper' import { CheckBalanceWrapper } from './iframe/check-balance-iframe/CheckBalanceWrapper'
import { CopyIcon } from 'lucide-react' import { CopyIcon } from 'lucide-react'
const BACKEND_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'
// Generate a random nonce // Generate a random nonce
function generateNonce() { function generateNonce() {
return Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2); return Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2);
@ -25,7 +27,7 @@ export function SIWEAuth() {
const checkSession = async () => { const checkSession = async () => {
try { try {
setSessionStatus('checking') setSessionStatus('checking')
const response = await fetch('http://localhost:8000/auth/session', { const response = await fetch(`${BACKEND_URL}/auth/session`, {
method: 'GET', method: 'GET',
credentials: 'include', credentials: 'include',
}) })
@ -171,7 +173,7 @@ Issued At: ${issuedAt}`
setDebugInfo(prev => `${prev}\nRaw signature: ${signedMessage}`) setDebugInfo(prev => `${prev}\nRaw signature: ${signedMessage}`)
// Try using the raw signature directly // Try using the raw signature directly
const response = await fetch('http://localhost:8000/auth/validate', { const response = await fetch(`${BACKEND_URL}/auth/validate`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',

View File

@ -4,6 +4,8 @@ import { useState, useEffect } from 'react'
import { Button } from '@workspace/ui/components/button' import { Button } from '@workspace/ui/components/button'
import { Wallet } from 'ethers' // Add this to your package.json if not already there import { Wallet } from 'ethers' // Add this to your package.json if not already there
const BACKEND_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'
export function DirectKeyAuth() { export function DirectKeyAuth() {
const [sessionStatus, setSessionStatus] = useState<'checking' | 'authenticated' | 'unauthenticated'>('checking') const [sessionStatus, setSessionStatus] = useState<'checking' | 'authenticated' | 'unauthenticated'>('checking')
const [sessionData, setSessionData] = useState<any>(null) const [sessionData, setSessionData] = useState<any>(null)
@ -14,7 +16,7 @@ export function DirectKeyAuth() {
const checkSession = async () => { const checkSession = async () => {
try { try {
setSessionStatus('checking') setSessionStatus('checking')
const response = await fetch('http://localhost:8000/auth/session', { const response = await fetch(`${BACKEND_URL}/auth/session`, {
method: 'GET', method: 'GET',
credentials: 'include', credentials: 'include',
}) })
@ -79,7 +81,7 @@ Issued At: ${issuedAt}`;
console.log('Generated signature:', signature); console.log('Generated signature:', signature);
// Send to backend // Send to backend
const response = await fetch('http://localhost:8000/auth/validate', { const response = await fetch(`${BACKEND_URL}/auth/validate`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',

View File

@ -2,6 +2,8 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useGQLClient } from '@/context' import { useGQLClient } from '@/context'
const BACKEND_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'
export function GQLTest() { export function GQLTest() {
const [testResponse, setTestResponse] = useState<string>('Testing connection...') const [testResponse, setTestResponse] = useState<string>('Testing connection...')
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@ -11,7 +13,7 @@ export function GQLTest() {
async function testGQLConnection() { async function testGQLConnection() {
try { try {
// Try a direct GraphQL query using fetch // Try a direct GraphQL query using fetch
const response = await fetch('http://localhost:8000/graphql', { const response = await fetch(`${BACKEND_URL}/graphql`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@ -50,7 +52,7 @@ export function GQLTest() {
setTestResponse('Testing direct connection...') setTestResponse('Testing direct connection...')
setError(null) setError(null)
const response = await fetch('http://localhost:8000/auth/session', { const response = await fetch(`${BACKEND_URL}/auth/session`, {
method: 'GET', method: 'GET',
credentials: 'include', credentials: 'include',
}) })

View File

@ -10,7 +10,7 @@
import { ConfigureStep } from '@/components/onboarding/configure-step' import { ConfigureStep } from '@/components/onboarding/configure-step'
import { ConnectStep } from '@/components/onboarding/connect-step' import { ConnectStep } from '@/components/onboarding/connect-step'
import { DeployStep } from '@/components/onboarding/deploy-step' import { DeployStep } from '@/components/onboarding/deploy-step'
import { useOnboarding } from '@/components/onboarding/store' import { useOnboarding } from '@/components/onboarding/useOnboarding'
import { ScrollArea } from '@workspace/ui/components/scroll-area' import { ScrollArea } from '@workspace/ui/components/scroll-area'
import { SidebarNav } from './sidebar' import { SidebarNav } from './sidebar'

View File

@ -1,22 +1,33 @@
// src/components/onboarding/configure-step/configure-step.tsx // src/components/onboarding/configure-step/configure-step.tsx
'use client' 'use client'
import { useState, useEffect } from 'react' import { useOnboarding } from '@/components/onboarding/useOnboarding'
import { PlusCircle, Loader2, AlertTriangle, Info } from 'lucide-react'
import { useTheme } from 'next-themes'
import { useOnboarding } from '@/components/onboarding/store'
import { useGQLClient } from '@/context' import { useGQLClient } from '@/context'
import { useWallet } from '@/context/WalletContext' import { useWallet } from '@/context/WalletContext'
import { Button } from '@workspace/ui/components/button'
import { Input } from '@workspace/ui/components/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@workspace/ui/components/select'
import { Checkbox } from '@workspace/ui/components/checkbox'
import { Label } from '@workspace/ui/components/label'
import { Alert, AlertDescription } from '@workspace/ui/components/alert' import { Alert, AlertDescription } from '@workspace/ui/components/alert'
import { Card, CardContent, CardHeader, CardTitle } from '@workspace/ui/components/card'
import { Badge } from '@workspace/ui/components/badge' import { Badge } from '@workspace/ui/components/badge'
import { Button } from '@workspace/ui/components/button'
import {
Card,
CardContent,
CardHeader,
CardTitle
} from '@workspace/ui/components/card'
import { Checkbox } from '@workspace/ui/components/checkbox'
import { Input } from '@workspace/ui/components/input'
import { Label } from '@workspace/ui/components/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@workspace/ui/components/select'
import { AlertTriangle, Info, Loader2, PlusCircle } from 'lucide-react'
import { useTheme } from 'next-themes'
import { useEffect, useState } from 'react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { adaptDeployers } from '../../../utils/typeAdapters'; import { adaptDeployers } from '../../../utils/typeAdapters'
interface Deployer { interface Deployer {
deployerLrn: string deployerLrn: string
@ -44,23 +55,21 @@ export function ConfigureStep() {
// Form state // Form state
const [deployOption, setDeployOption] = useState<'auction' | 'lrn'>( const [deployOption, setDeployOption] = useState<'auction' | 'lrn'>(
formData.deploymentType as ('auction' | 'lrn') || 'lrn' // Default to LRN for simplicity (formData.deploymentType as 'auction' | 'lrn') || 'lrn' // Default to LRN for simplicity
) )
const [numberOfDeployers, setNumberOfDeployers] = useState<string>( const [numberOfDeployers, setNumberOfDeployers] = useState<string>(
formData.deployerCount || "1" formData.deployerCount || '1'
)
const [maxPrice, setMaxPrice] = useState<string>(
formData.maxPrice || "1000"
) )
const [maxPrice, setMaxPrice] = useState<string>(formData.maxPrice || '1000')
const [selectedLrn, setSelectedLrn] = useState<string>( const [selectedLrn, setSelectedLrn] = useState<string>(
formData.selectedLrn || "" formData.selectedLrn || ''
) )
const [selectedOrg, setSelectedOrg] = useState<string>( const [selectedOrg, setSelectedOrg] = useState<string>(
formData.selectedOrg || "" formData.selectedOrg || ''
) )
const [envVars, setEnvVars] = useState<{ key: string; value: string; environments: string[] }[]>([ const [envVars, setEnvVars] = useState<
{ key: '', value: '', environments: ['Production'] } { key: string; value: string; environments: string[] }[]
]) >([{ key: '', value: '', environments: ['Production'] }])
// Contexts // Contexts
const gqlClient = useGQLClient() const gqlClient = useGQLClient()
@ -81,10 +90,15 @@ export function ConfigureStep() {
// Initialize environment variables from formData if available // Initialize environment variables from formData if available
useEffect(() => { useEffect(() => {
if (formData.environmentVariables && Array.isArray(formData.environmentVariables)) { if (
setEnvVars(formData.environmentVariables.length > 0 ? formData.environmentVariables : [ formData.environmentVariables &&
{ key: '', value: '', environments: ['Production'] } Array.isArray(formData.environmentVariables)
]) ) {
setEnvVars(
formData.environmentVariables.length > 0
? formData.environmentVariables
: [{ key: '', value: '', environments: ['Production'] }]
)
} }
}, [formData.environmentVariables]) }, [formData.environmentVariables])
@ -94,10 +108,14 @@ export function ConfigureStep() {
setIsLoadingDeployers(true) setIsLoadingDeployers(true)
const deployersData = await gqlClient.getDeployers() const deployersData = await gqlClient.getDeployers()
console.log('Available deployers:', deployersData) console.log('Available deployers:', deployersData)
setDeployers(adaptDeployers(deployersData.deployers || [])); setDeployers(adaptDeployers(deployersData.deployers || []))
// Auto-select first deployer if available and none selected // Auto-select first deployer if available and none selected
if (deployersData.deployers && deployersData.deployers.length > 0 && !selectedLrn) { if (
deployersData.deployers &&
deployersData.deployers.length > 0 &&
!selectedLrn
) {
setSelectedLrn(deployersData.deployers[0]!.deployerLrn) setSelectedLrn(deployersData.deployers[0]!.deployerLrn)
} }
} catch (error) { } catch (error) {
@ -117,7 +135,11 @@ export function ConfigureStep() {
setOrganizations(orgsData.organizations || []) setOrganizations(orgsData.organizations || [])
// Auto-select first organization if available and none selected // Auto-select first organization if available and none selected
if (orgsData.organizations && orgsData.organizations.length > 0 && !selectedOrg) { if (
orgsData.organizations &&
orgsData.organizations.length > 0 &&
!selectedOrg
) {
setSelectedOrg(orgsData.organizations[0]!.slug) setSelectedOrg(orgsData.organizations[0]!.slug)
} }
} catch (error) { } catch (error) {
@ -130,7 +152,10 @@ export function ConfigureStep() {
// Add an empty environment variable row // Add an empty environment variable row
const addEnvVar = () => { const addEnvVar = () => {
setEnvVars([...envVars, { key: '', value: '', environments: ['Production'] }]) setEnvVars([
...envVars,
{ key: '', value: '', environments: ['Production'] }
])
} }
// Remove environment variable row // Remove environment variable row
@ -141,7 +166,11 @@ export function ConfigureStep() {
} }
// Update environment variable // Update environment variable
const updateEnvVar = (index: number, field: 'key' | 'value', value: string) => { const updateEnvVar = (
index: number,
field: 'key' | 'value',
value: string
) => {
const newEnvVars = [...envVars] const newEnvVars = [...envVars]
if (newEnvVars[index]) { if (newEnvVars[index]) {
newEnvVars[index][field] = value newEnvVars[index][field] = value
@ -155,9 +184,10 @@ export function ConfigureStep() {
if (newEnvVars[index]?.environments) { if (newEnvVars[index]?.environments) {
const currentEnvs = newEnvVars[index].environments const currentEnvs = newEnvVars[index].environments
if (currentEnvs.includes(environment)) { if (currentEnvs.includes(environment)) {
newEnvVars[index].environments = currentEnvs.filter(env => env !== environment) newEnvVars[index].environments = currentEnvs.filter(
(env) => env !== environment
)
} else { } else {
newEnvVars[index].environments = [...currentEnvs, environment] newEnvVars[index].environments = [...currentEnvs, environment]
} }
@ -177,7 +207,7 @@ export function ConfigureStep() {
} }
// Get selected deployer details // Get selected deployer details
const selectedDeployer = deployers.find(d => d.deployerLrn === selectedLrn) const selectedDeployer = deployers.find((d) => d.deployerLrn === selectedLrn)
// Validate form // Validate form
const canProceed = () => { const canProceed = () => {
@ -195,7 +225,9 @@ export function ConfigureStep() {
} }
// Filter out empty environment variables // Filter out empty environment variables
const validEnvVars = envVars.filter(env => env.key.trim() && env.value.trim()) const validEnvVars = envVars.filter(
(env) => env.key.trim() && env.value.trim()
)
// Save configuration to form data // Save configuration to form data
setFormData({ setFormData({
@ -221,19 +253,44 @@ export function ConfigureStep() {
// Get deployment mode info // Get deployment mode info
const isTemplateMode = formData.deploymentMode === 'template' const isTemplateMode = formData.deploymentMode === 'template'
const selectedItem = isTemplateMode ? formData.template?.name : formData.githubRepo const selectedItem = isTemplateMode
? formData.template?.name
: formData.githubRepo
return ( return (
<div className="w-full h-full flex flex-col p-8 overflow-y-auto"> <div className="w-full h-full flex flex-col p-8 overflow-y-auto">
{/* Configure icon and header */} {/* Configure icon and header */}
<div className="flex flex-col items-center justify-center mb-8"> <div className="flex flex-col items-center justify-center mb-8">
<div className="mb-4"> <div className="mb-4">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" className={isDarkMode ? "text-white" : "text-black"}> <svg
<path d="M12 20h9" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/> width="40"
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/> height="40"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={isDarkMode ? 'text-white' : 'text-black'}
>
<path
d="M12 20h9"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg> </svg>
</div> </div>
<h2 className={`text-2xl font-medium text-center mb-2 ${isDarkMode ? "text-white" : "text-zinc-900"}`}>Configure</h2> <h2
className={`text-2xl font-medium text-center mb-2 ${isDarkMode ? 'text-white' : 'text-zinc-900'}`}
>
Configure
</h2>
<p className={`text-center text-zinc-500 max-w-md`}> <p className={`text-center text-zinc-500 max-w-md`}>
Define the deployment type Define the deployment type
</p> </p>
@ -252,7 +309,9 @@ export function ConfigureStep() {
<div className="space-y-2 text-sm"> <div className="space-y-2 text-sm">
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-muted-foreground">Type:</span> <span className="text-muted-foreground">Type:</span>
<Badge variant="secondary">{isTemplateMode ? 'Template' : 'Repository'}</Badge> <Badge variant="secondary">
{isTemplateMode ? 'Template' : 'Repository'}
</Badge>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-muted-foreground">Source:</span> <span className="text-muted-foreground">Source:</span>
@ -268,24 +327,33 @@ export function ConfigureStep() {
{/* Organization Selection */} {/* Organization Selection */}
<div className="mb-6"> <div className="mb-6">
<Label htmlFor="organization" className={`text-sm mb-2 block ${isDarkMode ? 'text-zinc-400' : 'text-zinc-700'}`}> <Label
htmlFor="organization"
className={`text-sm mb-2 block ${isDarkMode ? 'text-zinc-400' : 'text-zinc-700'}`}
>
Organization * Organization *
</Label> </Label>
{isLoadingOrgs ? ( {isLoadingOrgs ? (
<div className="flex items-center justify-center p-3 border rounded-md"> <div className="flex items-center justify-center p-3 border rounded-md">
<Loader2 className="h-4 w-4 animate-spin mr-2" /> <Loader2 className="h-4 w-4 animate-spin mr-2" />
<span className="text-sm text-muted-foreground">Loading organizations...</span> <span className="text-sm text-muted-foreground">
Loading organizations...
</span>
</div> </div>
) : organizations.length === 0 ? ( ) : organizations.length === 0 ? (
<Alert> <Alert>
<AlertTriangle className="h-4 w-4" /> <AlertTriangle className="h-4 w-4" />
<AlertDescription> <AlertDescription>
No organizations found. You need to be part of at least one organization. No organizations found. You need to be part of at least one
organization.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
) : ( ) : (
<Select value={selectedOrg} onValueChange={setSelectedOrg}> <Select value={selectedOrg} onValueChange={setSelectedOrg}>
<SelectTrigger id="organization" className={isDarkMode ? 'border-zinc-700' : 'border-zinc-300'}> <SelectTrigger
id="organization"
className={isDarkMode ? 'border-zinc-700' : 'border-zinc-300'}
>
<SelectValue placeholder="Select organization" /> <SelectValue placeholder="Select organization" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -301,24 +369,38 @@ export function ConfigureStep() {
{/* Deployment options */} {/* Deployment options */}
<div className="mb-6"> <div className="mb-6">
<Label className={`text-sm mb-3 block ${isDarkMode ? 'text-zinc-400' : 'text-zinc-700'}`}> <Label
className={`text-sm mb-3 block ${isDarkMode ? 'text-zinc-400' : 'text-zinc-700'}`}
>
Deployment Type Deployment Type
</Label> </Label>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<Button <Button
variant={deployOption === 'lrn' ? "default" : "outline"} variant={deployOption === 'lrn' ? 'default' : 'outline'}
className={`py-3 ${deployOption === 'lrn' className={`py-3 ${
? (isDarkMode ? 'bg-zinc-800 text-white' : 'bg-zinc-800 text-white') deployOption === 'lrn'
: (isDarkMode ? 'bg-transparent border-zinc-700 text-zinc-400' : 'bg-transparent border-zinc-300 text-zinc-600')}`} ? isDarkMode
? 'bg-zinc-800 text-white'
: 'bg-zinc-800 text-white'
: isDarkMode
? 'bg-transparent border-zinc-700 text-zinc-400'
: 'bg-transparent border-zinc-300 text-zinc-600'
}`}
onClick={() => toggleDeployOption('lrn')} onClick={() => toggleDeployOption('lrn')}
> >
Deployer LRN Deployer LRN
</Button> </Button>
<Button <Button
variant={deployOption === 'auction' ? "default" : "outline"} variant={deployOption === 'auction' ? 'default' : 'outline'}
className={`py-3 ${deployOption === 'auction' className={`py-3 ${
? (isDarkMode ? 'bg-zinc-800 text-white' : 'bg-zinc-800 text-white') deployOption === 'auction'
: (isDarkMode ? 'bg-transparent border-zinc-700 text-zinc-400' : 'bg-transparent border-zinc-300 text-zinc-600')}`} ? isDarkMode
? 'bg-zinc-800 text-white'
: 'bg-zinc-800 text-white'
: isDarkMode
? 'bg-transparent border-zinc-700 text-zinc-400'
: 'bg-transparent border-zinc-300 text-zinc-600'
}`}
onClick={() => toggleDeployOption('auction')} onClick={() => toggleDeployOption('auction')}
> >
Create Auction Create Auction
@ -329,13 +411,18 @@ export function ConfigureStep() {
{deployOption === 'lrn' ? ( {deployOption === 'lrn' ? (
/* LRN Deployment Settings */ /* LRN Deployment Settings */
<div className="mb-6"> <div className="mb-6">
<Label htmlFor="lrn" className={`text-sm mb-2 block ${isDarkMode ? 'text-zinc-400' : 'text-zinc-700'}`}> <Label
htmlFor="lrn"
className={`text-sm mb-2 block ${isDarkMode ? 'text-zinc-400' : 'text-zinc-700'}`}
>
Select Deployer LRN * Select Deployer LRN *
</Label> </Label>
{isLoadingDeployers ? ( {isLoadingDeployers ? (
<div className="flex items-center justify-center p-3 border rounded-md"> <div className="flex items-center justify-center p-3 border rounded-md">
<Loader2 className="h-4 w-4 animate-spin mr-2" /> <Loader2 className="h-4 w-4 animate-spin mr-2" />
<span className="text-sm text-muted-foreground">Loading deployers...</span> <span className="text-sm text-muted-foreground">
Loading deployers...
</span>
</div> </div>
) : deployers.length === 0 ? ( ) : deployers.length === 0 ? (
<Alert> <Alert>
@ -347,12 +434,20 @@ export function ConfigureStep() {
) : ( ) : (
<> <>
<Select value={selectedLrn} onValueChange={setSelectedLrn}> <Select value={selectedLrn} onValueChange={setSelectedLrn}>
<SelectTrigger id="lrn" className={isDarkMode ? 'border-zinc-700' : 'border-zinc-300'}> <SelectTrigger
id="lrn"
className={
isDarkMode ? 'border-zinc-700' : 'border-zinc-300'
}
>
<SelectValue placeholder="Select a deployer" /> <SelectValue placeholder="Select a deployer" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{deployers.map((deployer) => ( {deployers.map((deployer) => (
<SelectItem key={deployer.deployerLrn} value={deployer.deployerLrn}> <SelectItem
key={deployer.deployerLrn}
value={deployer.deployerLrn}
>
<div className="flex flex-col"> <div className="flex flex-col">
<span>{deployer.deployerLrn}</span> <span>{deployer.deployerLrn}</span>
{deployer.minimumPayment && ( {deployer.minimumPayment && (
@ -370,10 +465,19 @@ export function ConfigureStep() {
{selectedDeployer && ( {selectedDeployer && (
<div className="mt-3 p-3 bg-muted rounded-md"> <div className="mt-3 p-3 bg-muted rounded-md">
<div className="text-sm space-y-1"> <div className="text-sm space-y-1">
<div><strong>API URL:</strong> {selectedDeployer.deployerApiUrl}</div> <div>
<div><strong>Base Domain:</strong> {selectedDeployer.baseDomain}</div> <strong>API URL:</strong>{' '}
{selectedDeployer.deployerApiUrl}
</div>
<div>
<strong>Base Domain:</strong>{' '}
{selectedDeployer.baseDomain}
</div>
{selectedDeployer.minimumPayment && ( {selectedDeployer.minimumPayment && (
<div><strong>Minimum Payment:</strong> {selectedDeployer.minimumPayment}</div> <div>
<strong>Minimum Payment:</strong>{' '}
{selectedDeployer.minimumPayment}
</div>
)} )}
</div> </div>
</div> </div>
@ -385,11 +489,20 @@ export function ConfigureStep() {
/* Auction Settings */ /* Auction Settings */
<div className="grid grid-cols-2 gap-4 mb-6"> <div className="grid grid-cols-2 gap-4 mb-6">
<div> <div>
<Label htmlFor="deployers" className={`text-sm mb-2 block ${isDarkMode ? 'text-zinc-400' : 'text-zinc-700'}`}> <Label
htmlFor="deployers"
className={`text-sm mb-2 block ${isDarkMode ? 'text-zinc-400' : 'text-zinc-700'}`}
>
Number of Deployers Number of Deployers
</Label> </Label>
<Select value={numberOfDeployers} onValueChange={setNumberOfDeployers}> <Select
<SelectTrigger id="deployers" className={isDarkMode ? 'border-zinc-700' : 'border-zinc-300'}> value={numberOfDeployers}
onValueChange={setNumberOfDeployers}
>
<SelectTrigger
id="deployers"
className={isDarkMode ? 'border-zinc-700' : 'border-zinc-300'}
>
<SelectValue placeholder="Select number" /> <SelectValue placeholder="Select number" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -402,11 +515,17 @@ export function ConfigureStep() {
</Select> </Select>
</div> </div>
<div> <div>
<Label htmlFor="maxPrice" className={`text-sm mb-2 block ${isDarkMode ? 'text-zinc-400' : 'text-zinc-700'}`}> <Label
htmlFor="maxPrice"
className={`text-sm mb-2 block ${isDarkMode ? 'text-zinc-400' : 'text-zinc-700'}`}
>
Maximum Price (aint) Maximum Price (aint)
</Label> </Label>
<Select value={maxPrice} onValueChange={setMaxPrice}> <Select value={maxPrice} onValueChange={setMaxPrice}>
<SelectTrigger id="maxPrice" className={isDarkMode ? 'border-zinc-700' : 'border-zinc-300'}> <SelectTrigger
id="maxPrice"
className={isDarkMode ? 'border-zinc-700' : 'border-zinc-300'}
>
<SelectValue placeholder="Select price" /> <SelectValue placeholder="Select price" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -422,10 +541,14 @@ export function ConfigureStep() {
{/* Payment Address */} {/* Payment Address */}
<div className="mb-6"> <div className="mb-6">
<Label className={`text-sm mb-2 block ${isDarkMode ? 'text-zinc-400' : 'text-zinc-700'}`}> <Label
className={`text-sm mb-2 block ${isDarkMode ? 'text-zinc-400' : 'text-zinc-700'}`}
>
Payment Address Payment Address
</Label> </Label>
<div className={`p-3 border rounded-md bg-muted ${isDarkMode ? 'border-zinc-700' : 'border-zinc-300'}`}> <div
className={`p-3 border rounded-md bg-muted ${isDarkMode ? 'border-zinc-700' : 'border-zinc-300'}`}
>
<div className="text-sm font-mono break-all"> <div className="text-sm font-mono break-all">
{wallet?.address || 'No wallet connected'} {wallet?.address || 'No wallet connected'}
</div> </div>
@ -434,12 +557,19 @@ export function ConfigureStep() {
{/* Environment Variables */} {/* Environment Variables */}
<div className="mb-6"> <div className="mb-6">
<Label className={`text-sm font-medium mb-2 block ${isDarkMode ? 'text-white' : 'text-zinc-900'}`}> <Label
className={`text-sm font-medium mb-2 block ${isDarkMode ? 'text-white' : 'text-zinc-900'}`}
>
Environment Variables Environment Variables
</Label> </Label>
<div className={`border rounded-md p-4 ${isDarkMode ? 'border-zinc-700' : 'border-zinc-300'}`}> <div
className={`border rounded-md p-4 ${isDarkMode ? 'border-zinc-700' : 'border-zinc-300'}`}
>
{envVars.map((envVar, index) => ( {envVars.map((envVar, index) => (
<div key={index} className="space-y-2 mb-4 pb-4 border-b border-muted last:border-b-0 last:mb-0 last:pb-0"> <div
key={index}
className="space-y-2 mb-4 pb-4 border-b border-muted last:border-b-0 last:mb-0 last:pb-0"
>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<Input <Input
placeholder="KEY" placeholder="KEY"
@ -450,12 +580,16 @@ export function ConfigureStep() {
<Input <Input
placeholder="VALUE" placeholder="VALUE"
value={envVar.value} value={envVar.value}
onChange={(e) => updateEnvVar(index, 'value', e.target.value)} onChange={(e) =>
updateEnvVar(index, 'value', e.target.value)
}
className={`bg-transparent ${isDarkMode ? 'border-zinc-700' : 'border-zinc-300'}`} className={`bg-transparent ${isDarkMode ? 'border-zinc-700' : 'border-zinc-300'}`}
/> />
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<span className="text-xs text-muted-foreground">Environments:</span> <span className="text-xs text-muted-foreground">
Environments:
</span>
{['Production', 'Preview', 'Development'].map((env) => ( {['Production', 'Preview', 'Development'].map((env) => (
<div key={env} className="flex items-center gap-1"> <div key={env} className="flex items-center gap-1">
<Checkbox <Checkbox

View File

@ -1,24 +1,36 @@
// src/components/onboarding/connect-step/connect-step.tsx // src/components/onboarding/connect-step/connect-step.tsx
'use client' 'use client'
import { useState, useEffect } from 'react' import { GitHubBackendAuth } from '@/components/GitHubBackendAuth'
import { Github, Wallet, CheckCircle2, AlertTriangle, Loader2, ExternalLink, ChevronDown } from 'lucide-react' import { useOnboarding } from '@/components/onboarding/useOnboarding'
import { useTheme } from 'next-themes' import { AVAILABLE_TEMPLATES, type TemplateDetail } from '@/constants/templates'
import { SignIn } from '@clerk/nextjs'
import { useOnboarding } from '@/components/onboarding/store'
import { useAuthStatus } from '@/hooks/useAuthStatus' import { useAuthStatus } from '@/hooks/useAuthStatus'
import { useRepoData } from '@/hooks/useRepoData' import { useRepoData } from '@/hooks/useRepoData'
import type { Template } from '@/types/onboarding'
import { adaptOptionalTemplate } from '@/utils/typeAdapters'
import { SignIn } from '@clerk/nextjs'
import { Alert, AlertDescription } from '@workspace/ui/components/alert'
import { Button } from '@workspace/ui/components/button' import { Button } from '@workspace/ui/components/button'
import { Card, CardContent } from '@workspace/ui/components/card' import { Card, CardContent } from '@workspace/ui/components/card'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger
} from '@workspace/ui/components/collapsible'
import { Input } from '@workspace/ui/components/input' import { Input } from '@workspace/ui/components/input'
import { Label } from '@workspace/ui/components/label' import { Label } from '@workspace/ui/components/label'
import { Alert, AlertDescription } from '@workspace/ui/components/alert' import {
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@workspace/ui/components/collapsible' AlertTriangle,
CheckCircle2,
ChevronDown,
ExternalLink,
Github,
Loader2,
Wallet
} from 'lucide-react'
import { useTheme } from 'next-themes'
import { useEffect, useState } from 'react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { GitHubBackendAuth } from '@/components/GitHubBackendAuth'
import { AVAILABLE_TEMPLATES, type TemplateDetail } from '@/constants/templates'
import { Template } from '@/types/onboarding'
import { adaptOptionalTemplate } from '@/utils/typeAdapters'
interface Repository { interface Repository {
id: string | number id: string | number
@ -33,11 +45,15 @@ export function ConnectStep() {
const [mounted, setMounted] = useState(false) const [mounted, setMounted] = useState(false)
// Repository vs Template selection // Repository vs Template selection
const [selectedRepo, setSelectedRepo] = useState<string>(formData.githubRepo || '') const [selectedRepo, setSelectedRepo] = useState<string>(
formData.githubRepo || ''
)
const [selectedTemplate, setSelectedTemplate] = useState( const [selectedTemplate, setSelectedTemplate] = useState(
adaptOptionalTemplate(formData.template) adaptOptionalTemplate(formData.template)
) )
const [projectName, setProjectName] = useState<string>(formData.projectName || '') const [projectName, setProjectName] = useState<string>(
formData.projectName || ''
)
const [isImportMode, setIsImportMode] = useState(true) const [isImportMode, setIsImportMode] = useState(true)
// Auth status and warning display // Auth status and warning display
@ -94,7 +110,9 @@ export function ConnectStep() {
template: template, template: template,
githubRepo: '', githubRepo: '',
deploymentMode: 'template', deploymentMode: 'template',
projectName: projectName || `my-${template.name.toLowerCase().replace(/[^a-z0-9]/g, '-')}` projectName:
projectName ||
`my-${template.name.toLowerCase().replace(/[^a-z0-9]/g, '-')}`
}) })
} }
@ -163,7 +181,8 @@ export function ConnectStep() {
} }
// For repository import, project name is optional but we'll use repo name as fallback // For repository import, project name is optional but we'll use repo name as fallback
const finalProjectName = projectName.trim() || (isImportMode ? selectedRepo.split('/')[1] : '') const finalProjectName =
projectName.trim() || (isImportMode ? selectedRepo.split('/')[1] : '')
// Set final form data and proceed // Set final form data and proceed
setFormData({ setFormData({
@ -182,7 +201,9 @@ export function ConnectStep() {
<div className="w-full h-full flex items-center justify-center"> <div className="w-full h-full flex items-center justify-center">
<div className="text-center"> <div className="text-center">
<Loader2 className="h-8 w-8 animate-spin mx-auto mb-4" /> <Loader2 className="h-8 w-8 animate-spin mx-auto mb-4" />
<p className="text-sm text-zinc-500">Loading authentication status...</p> <p className="text-sm text-zinc-500">
Loading authentication status...
</p>
</div> </div>
</div> </div>
) )
@ -196,7 +217,9 @@ export function ConnectStep() {
<div className="max-w-2xl w-full mx-auto"> <div className="max-w-2xl w-full mx-auto">
{/* Header */} {/* Header */}
<div className="text-center mb-8"> <div className="text-center mb-8">
<h2 className={`text-2xl font-medium ${isDarkMode ? "text-white" : "text-zinc-900"} mb-2`}> <h2
className={`text-2xl font-medium ${isDarkMode ? 'text-white' : 'text-zinc-900'} mb-2`}
>
Connect Connect
</h2> </h2>
<p className="text-zinc-500 mb-6"> <p className="text-zinc-500 mb-6">
@ -204,17 +227,20 @@ export function ConnectStep() {
</p> </p>
{/* GitHub Account Selector - Only show if multiple accounts */} {/* GitHub Account Selector - Only show if multiple accounts */}
{clerk.user?.externalAccounts && clerk.user.externalAccounts.length > 1 && ( {clerk.user?.externalAccounts &&
<div className="flex items-center justify-center mb-6"> clerk.user.externalAccounts.length > 1 && (
<div className="flex items-center gap-2 px-4 py-2 bg-zinc-100 dark:bg-zinc-800 rounded-md cursor-pointer hover:bg-zinc-200 dark:hover:bg-zinc-700"> <div className="flex items-center justify-center mb-6">
<Github className="h-4 w-4" /> <div className="flex items-center gap-2 px-4 py-2 bg-zinc-100 dark:bg-zinc-800 rounded-md cursor-pointer hover:bg-zinc-200 dark:hover:bg-zinc-700">
<span className="text-sm font-medium"> <Github className="h-4 w-4" />
{clerk.user?.externalAccounts?.find((acc: any) => acc.provider === 'github')?.username || 'git-account'} <span className="text-sm font-medium">
</span> {clerk.user?.externalAccounts?.find(
<ChevronDown className="h-4 w-4" /> (acc: any) => acc.provider === 'github'
)?.username || 'git-account'}
</span>
<ChevronDown className="h-4 w-4" />
</div>
</div> </div>
</div> )}
)}
</div> </div>
{/* Authentication Warning - Only show if not fully authenticated */} {/* Authentication Warning - Only show if not fully authenticated */}
@ -224,7 +250,10 @@ export function ConnectStep() {
<Alert className="mb-6 cursor-pointer hover:bg-amber-50 dark:hover:bg-amber-950/20"> <Alert className="mb-6 cursor-pointer hover:bg-amber-50 dark:hover:bg-amber-950/20">
<AlertTriangle className="h-4 w-4" /> <AlertTriangle className="h-4 w-4" />
<AlertDescription className="flex items-center justify-between w-full"> <AlertDescription className="flex items-center justify-between w-full">
<span>Authentication required to continue ({progress.completed}/{progress.total} complete)</span> <span>
Authentication required to continue ({progress.completed}/
{progress.total} complete)
</span>
<ChevronDown className="h-4 w-4" /> <ChevronDown className="h-4 w-4" />
</AlertDescription> </AlertDescription>
</Alert> </Alert>
@ -236,7 +265,9 @@ export function ConnectStep() {
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex items-center gap-3 mb-3"> <div className="flex items-center gap-3 mb-3">
<Github className="h-4 w-4" /> <Github className="h-4 w-4" />
<span className="text-sm font-medium">Sign in with Clerk</span> <span className="text-sm font-medium">
Sign in with Clerk
</span>
</div> </div>
<div className="scale-90 origin-top-left"> <div className="scale-90 origin-top-left">
<SignIn routing="hash" /> <SignIn routing="hash" />
@ -250,9 +281,15 @@ export function ConnectStep() {
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex items-center gap-3 mb-3"> <div className="flex items-center gap-3 mb-3">
<Github className="h-4 w-4" /> <Github className="h-4 w-4" />
<span className="text-sm font-medium">Connect GitHub Account</span> <span className="text-sm font-medium">
Connect GitHub Account
</span>
</div> </div>
<Button size="sm" variant="outline" onClick={() => window.open('/user-profile', '_blank')}> <Button
size="sm"
variant="outline"
onClick={() => window.open('/user-profile', '_blank')}
>
<ExternalLink className="h-3 w-3 mr-2" /> <ExternalLink className="h-3 w-3 mr-2" />
Connect GitHub Connect GitHub
</Button> </Button>
@ -265,7 +302,9 @@ export function ConnectStep() {
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex items-center gap-3 mb-3"> <div className="flex items-center gap-3 mb-3">
<Wallet className="h-4 w-4" /> <Wallet className="h-4 w-4" />
<span className="text-sm font-medium">Connect Wallet</span> <span className="text-sm font-medium">
Connect Wallet
</span>
</div> </div>
<Button size="sm" onClick={handleConnectWallet}> <Button size="sm" onClick={handleConnectWallet}>
Connect Wallet Connect Wallet
@ -274,17 +313,23 @@ export function ConnectStep() {
</Card> </Card>
)} )}
{missing.githubBackendSync && !missing.walletConnection && !missing.clerkGithub && ( {missing.githubBackendSync &&
<Card className="border-amber-200 bg-amber-50/50 dark:bg-amber-950/20"> !missing.walletConnection &&
<CardContent className="p-4"> !missing.clerkGithub && (
<div className="flex items-center gap-3 mb-3"> <Card className="border-amber-200 bg-amber-50/50 dark:bg-amber-950/20">
<Github className="h-4 w-4" /> <CardContent className="p-4">
<span className="text-sm font-medium">Sync GitHub Access</span> <div className="flex items-center gap-3 mb-3">
</div> <Github className="h-4 w-4" />
<GitHubBackendAuth onAuthStatusChange={handleGithubAuthChange} /> <span className="text-sm font-medium">
</CardContent> Sync GitHub Access
</Card> </span>
)} </div>
<GitHubBackendAuth
onAuthStatusChange={handleGithubAuthChange}
/>
</CardContent>
</Card>
)}
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
)} )}
@ -292,20 +337,22 @@ export function ConnectStep() {
{/* Mode Selection Tabs */} {/* Mode Selection Tabs */}
<div className="grid grid-cols-2 gap-1 p-1 bg-zinc-100 dark:bg-zinc-800 rounded-lg mb-6"> <div className="grid grid-cols-2 gap-1 p-1 bg-zinc-100 dark:bg-zinc-800 rounded-lg mb-6">
<Button <Button
variant={isImportMode ? "default" : "ghost"} variant={isImportMode ? 'default' : 'ghost'}
className={`${isImportMode className={`${
? 'bg-white dark:bg-zinc-700 shadow-sm' isImportMode
: 'bg-transparent hover:bg-white/50 dark:hover:bg-zinc-700/50' ? 'bg-white dark:bg-zinc-700 shadow-sm'
: 'bg-transparent hover:bg-white/50 dark:hover:bg-zinc-700/50'
}`} }`}
onClick={() => toggleMode('import')} onClick={() => toggleMode('import')}
> >
Import a repository Import a repository
</Button> </Button>
<Button <Button
variant={!isImportMode ? "default" : "ghost"} variant={!isImportMode ? 'default' : 'ghost'}
className={`${!isImportMode className={`${
? 'bg-white dark:bg-zinc-700 shadow-sm' !isImportMode
: 'bg-transparent hover:bg-white/50 dark:hover:bg-zinc-700/50' ? 'bg-white dark:bg-zinc-700 shadow-sm'
: 'bg-transparent hover:bg-white/50 dark:hover:bg-zinc-700/50'
}`} }`}
onClick={() => toggleMode('template')} onClick={() => toggleMode('template')}
> >
@ -327,7 +374,8 @@ export function ConnectStep() {
<Alert> <Alert>
<AlertTriangle className="h-4 w-4" /> <AlertTriangle className="h-4 w-4" />
<AlertDescription> <AlertDescription>
No repositories found. Make sure your GitHub account has repositories. No repositories found. Make sure your GitHub account has
repositories.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
</div> </div>
@ -346,9 +394,13 @@ export function ConnectStep() {
> >
<Github className="h-5 w-5 mr-3 text-zinc-500 flex-shrink-0" /> <Github className="h-5 w-5 mr-3 text-zinc-500 flex-shrink-0" />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="font-medium text-sm">{repo.full_name}</div> <div className="font-medium text-sm">
{repo.full_name}
</div>
{repo.description && ( {repo.description && (
<div className="text-xs text-zinc-500 truncate">{repo.description}</div> <div className="text-xs text-zinc-500 truncate">
{repo.description}
</div>
)} )}
</div> </div>
{selectedRepo === repo.full_name && ( {selectedRepo === repo.full_name && (
@ -361,7 +413,10 @@ export function ConnectStep() {
{/* Project Name Input for Repository Import */} {/* Project Name Input for Repository Import */}
{selectedRepo && ( {selectedRepo && (
<div className="mt-6 space-y-2"> <div className="mt-6 space-y-2">
<Label htmlFor="projectName" className="text-sm font-medium"> <Label
htmlFor="projectName"
className="text-sm font-medium"
>
Project Name Project Name
</Label> </Label>
<Input <Input
@ -382,38 +437,46 @@ export function ConnectStep() {
) : ( ) : (
/* Template Selection */ /* Template Selection */
<div className="space-y-4"> <div className="space-y-4">
{AVAILABLE_TEMPLATES.filter(t => !t.isComingSoon).map((template) => ( {AVAILABLE_TEMPLATES.filter((t) => !t.isComingSoon).map(
<div (template) => (
key={template.id} <div
className={`flex items-center p-4 rounded-lg border cursor-pointer transition-all ${ key={template.id}
selectedTemplate?.id === template.id className={`flex items-center p-4 rounded-lg border cursor-pointer transition-all ${
? 'border-blue-500 bg-blue-50 dark:bg-blue-950/20' selectedTemplate?.id === template.id
: 'border-zinc-200 dark:border-zinc-700 hover:border-zinc-300 dark:hover:border-zinc-600' ? 'border-blue-500 bg-blue-50 dark:bg-blue-950/20'
}`} : 'border-zinc-200 dark:border-zinc-700 hover:border-zinc-300 dark:hover:border-zinc-600'
onClick={() => handleTemplateSelect(template)} }`}
> onClick={() => handleTemplateSelect(template)}
{/* Template Icon */} >
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-zinc-100 dark:bg-zinc-800 mr-4"> {/* Template Icon */}
<div className="w-6 h-6 bg-zinc-600 dark:bg-zinc-400 rounded flex items-center justify-center text-xs font-bold text-white"> <div className="flex items-center justify-center w-10 h-10 rounded-lg bg-zinc-100 dark:bg-zinc-800 mr-4">
{template.icon === 'web' ? 'PWA' : template.icon === 'nextjs' ? 'N' : 'IMG'} <div className="w-6 h-6 bg-zinc-600 dark:bg-zinc-400 rounded flex items-center justify-center text-xs font-bold text-white">
{template.icon === 'web'
? 'PWA'
: template.icon === 'nextjs'
? 'N'
: 'IMG'}
</div>
</div> </div>
</div>
{/* Template Info */} {/* Template Info */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="font-medium text-sm mb-1">{template.name}</div> <div className="font-medium text-sm mb-1">
<div className="flex items-center text-xs text-zinc-500"> {template.name}
<Github className="h-3 w-3 mr-1" /> </div>
{template.repoFullName} <div className="flex items-center text-xs text-zinc-500">
<Github className="h-3 w-3 mr-1" />
{template.repoFullName}
</div>
</div> </div>
</div>
{/* Selection Indicator */} {/* Selection Indicator */}
{selectedTemplate?.id === template.id && ( {selectedTemplate?.id === template.id && (
<CheckCircle2 className="h-5 w-5 text-blue-500 flex-shrink-0" /> <CheckCircle2 className="h-5 w-5 text-blue-500 flex-shrink-0" />
)} )}
</div> </div>
))} )
)}
{/* Project Name Input for Templates */} {/* Project Name Input for Templates */}
{selectedTemplate && ( {selectedTemplate && (
@ -443,7 +506,12 @@ export function ConnectStep() {
</Button> </Button>
<Button <Button
onClick={handleNext} onClick={handleNext}
disabled={!isFullyAuthenticated || (isImportMode ? !selectedRepo : (!selectedTemplate || !projectName.trim()))} disabled={
!isFullyAuthenticated ||
(isImportMode
? !selectedRepo
: !selectedTemplate || !projectName.trim())
}
> >
Next Next
</Button> </Button>

View File

@ -1,19 +1,30 @@
// src/components/onboarding/deploy-step/deploy-step.tsx // src/components/onboarding/deploy-step/deploy-step.tsx
'use client' 'use client'
import { useState, useEffect } from 'react' import { useOnboarding } from '@/components/onboarding/useOnboarding'
import { useTheme } from 'next-themes'
import { Github, Loader2, AlertTriangle, CheckCircle2 } from 'lucide-react'
import { useOnboarding } from '@/components/onboarding/store'
import { useWallet } from '@/context/WalletContext' import { useWallet } from '@/context/WalletContext'
import { useDeployment } from '@/hooks/useDeployment' import { useDeployment } from '@/hooks/useDeployment'
import { useTemplateDeployment } from '@/hooks/useTemplate' import { useTemplateDeployment } from '@/hooks/useTemplate'
import { Button } from '@workspace/ui/components/button'
import { Progress } from '@workspace/ui/components/progress'
import { Dialog, DialogContent, DialogTitle, DialogDescription, DialogFooter } from '@workspace/ui/components/dialog'
import { Alert, AlertDescription } from '@workspace/ui/components/alert' import { Alert, AlertDescription } from '@workspace/ui/components/alert'
import { Card, CardContent, CardHeader, CardTitle } from '@workspace/ui/components/card'
import { Badge } from '@workspace/ui/components/badge' import { Badge } from '@workspace/ui/components/badge'
import { Button } from '@workspace/ui/components/button'
import {
Card,
CardContent,
CardHeader,
CardTitle
} from '@workspace/ui/components/card'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogTitle
} from '@workspace/ui/components/dialog'
import { Progress } from '@workspace/ui/components/progress'
import { AlertTriangle, CheckCircle2, Github, Loader2 } from 'lucide-react'
import { useTheme } from 'next-themes'
import { useEffect, useState } from 'react'
import { toast } from 'sonner' import { toast } from 'sonner'
export function DeployStep() { export function DeployStep() {
@ -29,7 +40,8 @@ export function DeployStep() {
// Contexts and hooks // Contexts and hooks
const { wallet } = useWallet() const { wallet } = useWallet()
const { deployRepository, isDeploying: isRepoDeploying } = useDeployment() const { deployRepository, isDeploying: isRepoDeploying } = useDeployment()
const { deployTemplate, isDeploying: isTemplateDeploying } = useTemplateDeployment() const { deployTemplate, isDeploying: isTemplateDeploying } =
useTemplateDeployment()
// Determine deployment type and get the right deploying state // Determine deployment type and get the right deploying state
const isTemplateMode = formData.deploymentMode === 'template' const isTemplateMode = formData.deploymentMode === 'template'
@ -53,7 +65,10 @@ export function DeployStep() {
return { return {
name: formData.githubRepo?.split('/').pop() || 'Repository', name: formData.githubRepo?.split('/').pop() || 'Repository',
source: formData.githubRepo || 'Unknown Repository', source: formData.githubRepo || 'Unknown Repository',
projectName: formData.projectName || formData.githubRepo?.split('/').pop() || 'New Project', projectName:
formData.projectName ||
formData.githubRepo?.split('/').pop() ||
'New Project',
type: 'Repository' type: 'Repository'
} }
} }
@ -105,7 +120,9 @@ export function DeployStep() {
} }
} catch (error) { } catch (error) {
console.error('Deployment failed:', error) console.error('Deployment failed:', error)
setDeploymentError(error instanceof Error ? error.message : 'Deployment failed') setDeploymentError(
error instanceof Error ? error.message : 'Deployment failed'
)
} }
} }
@ -125,8 +142,8 @@ export function DeployStep() {
organizationSlug: formData.selectedOrg, organizationSlug: formData.selectedOrg,
environmentVariables: formData.environmentVariables || [], environmentVariables: formData.environmentVariables || [],
deployerLrn: formData.selectedLrn deployerLrn: formData.selectedLrn
}; }
const result = await deployTemplate(config); const result = await deployTemplate(config)
// Save deployment results // Save deployment results
setFormData({ setFormData({
@ -156,7 +173,10 @@ export function DeployStep() {
organizationSlug: formData.selectedOrg, organizationSlug: formData.selectedOrg,
repository: formData.githubRepo, repository: formData.githubRepo,
branch: 'main', // Default branch branch: 'main', // Default branch
name: formData.projectName || formData.githubRepo.split('/').pop() || 'New Project', name:
formData.projectName ||
formData.githubRepo.split('/').pop() ||
'New Project',
environmentVariables: formData.environmentVariables || [] environmentVariables: formData.environmentVariables || []
} }
@ -194,18 +214,66 @@ export function DeployStep() {
<div className="max-w-md w-full mx-auto"> <div className="max-w-md w-full mx-auto">
{/* Deploy icon */} {/* Deploy icon */}
<div className="mx-auto mb-6 flex justify-center"> <div className="mx-auto mb-6 flex justify-center">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" className={isDarkMode ? "text-white" : "text-black"}> <svg
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/> width="48"
<polyline points="7.5 4.21 12 6.81 16.5 4.21" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/> height="48"
<polyline points="7.5 19.79 7.5 14.6 3 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/> viewBox="0 0 24 24"
<polyline points="21 12 16.5 14.6 16.5 19.79" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/> fill="none"
<polyline points="3.27 6.96 12 12.01 20.73 6.96" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/> xmlns="http://www.w3.org/2000/svg"
<line x1="12" y1="22.08" x2="12" y2="12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/> className={isDarkMode ? 'text-white' : 'text-black'}
>
<path
d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<polyline
points="7.5 4.21 12 6.81 16.5 4.21"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<polyline
points="7.5 19.79 7.5 14.6 3 12"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<polyline
points="21 12 16.5 14.6 16.5 19.79"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<polyline
points="3.27 6.96 12 12.01 20.73 6.96"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<line
x1="12"
y1="22.08"
x2="12"
y2="12"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg> </svg>
</div> </div>
{/* Deploy header */} {/* Deploy header */}
<h2 className={`text-2xl font-medium ${isDarkMode ? "text-white" : "text-zinc-900"} text-center mb-2`}> <h2
className={`text-2xl font-medium ${isDarkMode ? 'text-white' : 'text-zinc-900'} text-center mb-2`}
>
{isDeploying ? 'Deploying...' : 'Deploy'} {isDeploying ? 'Deploying...' : 'Deploy'}
</h2> </h2>
<p className="text-center text-zinc-500 mb-8"> <p className="text-center text-zinc-500 mb-8">
@ -226,7 +294,9 @@ export function DeployStep() {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Github className="h-4 w-4 text-muted-foreground" /> <Github className="h-4 w-4 text-muted-foreground" />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="font-medium text-sm">{deploymentInfo.projectName}</div> <div className="font-medium text-sm">
{deploymentInfo.projectName}
</div>
<div className="text-xs text-muted-foreground font-mono"> <div className="text-xs text-muted-foreground font-mono">
{deploymentInfo.source} {deploymentInfo.source}
</div> </div>
@ -240,18 +310,25 @@ export function DeployStep() {
</div> </div>
<div> <div>
<div className="text-muted-foreground">Deployer</div> <div className="text-muted-foreground">Deployer</div>
<div className="font-medium">{formData.selectedLrn ? 'LRN' : 'Auction'}</div> <div className="font-medium">
{formData.selectedLrn ? 'LRN' : 'Auction'}
</div>
</div> </div>
</div> </div>
{formData.environmentVariables && formData.environmentVariables.length > 0 && ( {formData.environmentVariables &&
<div className="pt-2"> formData.environmentVariables.length > 0 && (
<div className="text-xs text-muted-foreground mb-1">Environment Variables</div> <div className="pt-2">
<div className="text-xs"> <div className="text-xs text-muted-foreground mb-1">
{formData.environmentVariables.length} variable{formData.environmentVariables.length !== 1 ? 's' : ''} configured Environment Variables
</div>
<div className="text-xs">
{formData.environmentVariables.length} variable
{formData.environmentVariables.length !== 1 ? 's' : ''}{' '}
configured
</div>
</div> </div>
</div> )}
)}
</CardContent> </CardContent>
</Card> </Card>
@ -273,7 +350,8 @@ export function DeployStep() {
<AlertDescription> <AlertDescription>
<div className="font-medium">Deployment Successful!</div> <div className="font-medium">Deployment Successful!</div>
<div className="text-sm mt-1"> <div className="text-sm mt-1">
Your project has been deployed successfully. You'll be redirected to the project dashboard. Your project has been deployed successfully. You'll be
redirected to the project dashboard.
</div> </div>
</AlertDescription> </AlertDescription>
</Alert> </Alert>
@ -283,13 +361,21 @@ export function DeployStep() {
{isDeploying && ( {isDeploying && (
<div className="mb-8"> <div className="mb-8">
<div className="flex justify-between items-center mb-2"> <div className="flex justify-between items-center mb-2">
<div className={`${isDarkMode ? "text-white" : "text-zinc-900"} text-sm`}> <div
{isTemplateMode ? 'Creating repository from template...' : 'Deploying repository...'} className={`${isDarkMode ? 'text-white' : 'text-zinc-900'} text-sm`}
>
{isTemplateMode
? 'Creating repository from template...'
: 'Deploying repository...'}
</div> </div>
</div> </div>
<Progress value={undefined} className={`h-2 ${isDarkMode ? "bg-zinc-800" : "bg-zinc-200"}`} /> <Progress
value={undefined}
className={`h-2 ${isDarkMode ? 'bg-zinc-800' : 'bg-zinc-200'}`}
/>
<div className="text-xs text-muted-foreground mt-2"> <div className="text-xs text-muted-foreground mt-2">
This process may take several minutes. Please do not close this window. This process may take several minutes. Please do not close this
window.
</div> </div>
</div> </div>
)} )}
@ -298,7 +384,7 @@ export function DeployStep() {
<div className="flex justify-between items-center mt-4"> <div className="flex justify-between items-center mt-4">
<Button <Button
variant="outline" variant="outline"
className={`${isDarkMode ? "text-zinc-400 border-zinc-700" : "text-zinc-600 border-zinc-300"} bg-transparent`} className={`${isDarkMode ? 'text-zinc-400 border-zinc-700' : 'text-zinc-600 border-zinc-300'} bg-transparent`}
onClick={previousStep} onClick={previousStep}
disabled={isDeploying || deploymentSuccess} disabled={isDeploying || deploymentSuccess}
> >
@ -315,7 +401,7 @@ export function DeployStep() {
</Button> </Button>
) : isDeploying ? ( ) : isDeploying ? (
<Button <Button
className={`${isDarkMode ? "bg-zinc-700 text-zinc-300" : "bg-zinc-300 text-zinc-600"}`} className={`${isDarkMode ? 'bg-zinc-700 text-zinc-300' : 'bg-zinc-300 text-zinc-600'}`}
disabled disabled
> >
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
@ -327,9 +413,22 @@ export function DeployStep() {
onClick={handlePayAndDeploy} onClick={handlePayAndDeploy}
disabled={deploymentError !== null} disabled={deploymentError !== null}
> >
{formData.deploymentType === 'auction' ? 'Pay and Deploy' : 'Deploy'} {formData.deploymentType === 'auction'
<svg className="ml-2 h-4 w-4" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> ? 'Pay and Deploy'
<path d="M5 12H19M19 12L13 6M19 12L13 18" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/> : 'Deploy'}
<svg
className="ml-2 h-4 w-4"
fill="none"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5 12H19M19 12L13 6M19 12L13 18"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg> </svg>
</Button> </Button>
)} )}
@ -360,7 +459,9 @@ export function DeployStep() {
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-muted-foreground">Source:</span> <span className="text-muted-foreground">Source:</span>
<span className="font-mono text-xs">{deploymentInfo.source}</span> <span className="font-mono text-xs">
{deploymentInfo.source}
</span>
</div> </div>
</div> </div>
</div> </div>
@ -378,7 +479,9 @@ export function DeployStep() {
<div className="space-y-2"> <div className="space-y-2">
<h3 className="text-sm font-medium">Deployer</h3> <h3 className="text-sm font-medium">Deployer</h3>
<div className="text-sm"> <div className="text-sm">
<div className="font-mono text-xs">{formData.selectedLrn}</div> <div className="font-mono text-xs">
{formData.selectedLrn}
</div>
</div> </div>
</div> </div>
)} )}
@ -402,17 +505,10 @@ export function DeployStep() {
</div> </div>
<DialogFooter className="flex justify-end space-x-2"> <DialogFooter className="flex justify-end space-x-2">
<Button <Button variant="outline" onClick={handleCancelConfirm}>
variant="outline"
onClick={handleCancelConfirm}
>
Cancel Cancel
</Button> </Button>
<Button <Button onClick={handleConfirmDeploy}>Confirm Deployment</Button>
onClick={handleConfirmDeploy}
>
Confirm Deployment
</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@ -10,9 +10,7 @@
// Main component // Main component
export { default as Onboarding } from './Onboarding' export { default as Onboarding } from './Onboarding'
export { export { default as OnboardingDialog } from './OnboardingDialog'
default as OnboardingDialog,
} from './OnboardingDialog'
// Step components // Step components
export { ConfigureStep } from './configure-step' export { ConfigureStep } from './configure-step'
@ -27,7 +25,7 @@ export * from './common'
export * from './sidebar' export * from './sidebar'
// Store and hooks // Store and hooks
export { useOnboarding } from './store' export { useOnboarding } from './useOnboarding'
// Types // Types
export * from './types' export * from './types'

View File

@ -7,7 +7,7 @@
/** /**
* Available steps in the onboarding flow * Available steps in the onboarding flow
*/ */
export type Step = 'connect' | 'configure' | 'deploy' export type Step = 'connect' | 'configure' | 'deploy' | 'success'
/** /**
* Form data collected during the onboarding process * Form data collected during the onboarding process

View File

@ -19,7 +19,7 @@ export function Providers({ children }: { children: React.ReactNode }) {
const initGQLClient = async () => { const initGQLClient = async () => {
try { try {
const client = new GQLClient({ const client = new GQLClient({
gqlEndpoint: 'http://localhost:8000/graphql', gqlEndpoint: `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'}/graphql`,
}) })
setGqlClient(client) setGqlClient(client)
} catch (error) { } catch (error) {

View File

@ -11,6 +11,8 @@ import {
useCallback useCallback
} from 'react' } from 'react'
const BACKEND_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'
/** /**
* @interface BackendContextType * @interface BackendContextType
* @description Defines the structure of the BackendContext value. * @description Defines the structure of the BackendContext value.
@ -45,7 +47,7 @@ export const BackendProvider: React.FC<{ children: ReactNode }> = ({
// Check backend connection // Check backend connection
const checkBackendConnection = useCallback(async (): Promise<void> => { const checkBackendConnection = useCallback(async (): Promise<void> => {
try { try {
const response = await fetch('http://localhost:8000/auth/session', { const response = await fetch(`${BACKEND_URL}/auth/session`, {
method: 'GET', method: 'GET',
credentials: 'include', credentials: 'include',
}) })

View File

@ -0,0 +1,3 @@
REGISTRY_BOND_ID=
DEPLOYER_LRN=
AUTHORITY=

View File

@ -1,5 +1,9 @@
# deployer # deployer
The following steps are for deploying the frontend app to vaasl webapp deployer.
Run these steps in the `apps/deployer/` directory:
- Install dependencies - Install dependencies
```bash ```bash
@ -7,46 +11,60 @@
``` ```
```bash ```bash
brew install jq # if you do not have jq installed already # if you do not have jq installed already
brew install jq
# OR
sudo apt-get install jq
``` ```
- Run script to deploy app - Copy and update `.env`
- To deploy frontend app to `dashboard.staging.apps.snowballtools.com` ```
cp .env.example .env
```
```bash Set the required variables:
./deploy-frontend.staging.sh
```
- To deploy frontend app to `dashboard.apps.snowballtools.com` ```
REGISTRY_BOND_ID=230cfedda15e78edc8986dfcb870e1b618f65c56e38d2735476d2a8cb3f25e38
DEPLOYER_LRN=lrn://vaasl-provider/deployers/webapp-deployer-api.apps.vaasl.io
AUTHORITY=laconic
```
```bash - Set the `userKey` in [config.yml](./config.yml)
./deploy-frontend.sh
``` Note: The `userKey` should be the private key of the account owning the bond set in `config.yml` (account address: `laconic13maulvmjxnyx3g855vk0lsv5aptf3rpxskynef`)
- Set the app env values in [deploy-frontend.sh](./deploy-frontend.sh) (Refer to main [readme](/readme.md#3-configure-environment-variables)):
<!-- TODO: Set env values from a file -->
```sh
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: your_clerk_key
CLERK_SECRET_KEY: your_clerk_secret
NEXT_PUBLIC_WALLET_IFRAME_URL: https://wallet.laconic.com
NEXT_PUBLIC_LACONICD_CHAIN_ID: laconic-mainnet
NEXT_PUBLIC_API_URL: https://deploy-backend.apps.vaasl.io
NEXT_PUBLIC_GITHUB_FALLBACK_TOKEN: your_github_token
```
NOTE: Replace `your_clerk_key`, `your_clerk_secret` and `your_github_token` with actual values
- Run script to deploy app to `deploy-staging.laconic.co`
```bash
./deploy-frontend.sh
```
- Commit the updated [ApplicationRecord](records/application-record.yml) and [ApplicationDeploymentRequest](records/application-deployment-request.yml) files to the repository - Commit the updated [ApplicationRecord](records/application-record.yml) and [ApplicationDeploymentRequest](records/application-deployment-request.yml) files to the repository
## Notes
- Any config env can be updated in [records/application-deployment-request.yml](records/application-deployment-request.yml)
```yml
record:
...
config:
env:
LACONIC_HOSTED_CONFIG_app_server_url: https://snowballtools-base-api-001.apps.snowballtools.com
...
```
- On changing `LACONIC_HOSTED_CONFIG_app_github_clientid`, the GitHub client ID and secret need to be changed in backend config too
## Troubleshoot ## Troubleshoot
- Check deployment status in [web-app deployer](https://console.laconic.com/deployer). - Check deployment status in [web-app deployer](https://console.laconic.com/deployer).
- Check records in [registry console app](https://console.laconic.com/#/registry). - Check records in [registry console app](https://console.laconic.com/#/registry).
- If deployment fails due to low bond balance - If deployment fails due to low bond balance
- Check balances - Check balances
```bash ```bash
@ -54,11 +72,11 @@
pnpm laconic registry account get pnpm laconic registry account get
# Bond balance # Bond balance
pnpm laconic registry bond get --id 99c0e9aec0ac1b8187faa579be3b54f93fafb6060ac1fd29170b860df605be32 pnpm laconic registry bond get --id 230cfedda15e78edc8986dfcb870e1b618f65c56e38d2735476d2a8cb3f25e38
``` ```
- Command to refill bond - Command to refill bond
```bash ```bash
pnpm laconic registry bond refill --id 99c0e9aec0ac1b8187faa579be3b54f93fafb6060ac1fd29170b860df605be32 --type alnt --quantity 10000000 pnpm laconic registry bond refill --id 230cfedda15e78edc8986dfcb870e1b618f65c56e38d2735476d2a8cb3f25e38 --type alnt --quantity 10000000
``` ```

View File

@ -1,8 +1,8 @@
services: services:
registry: registry:
rpcEndpoint: https://laconicd-sapo.laconic.com rpcEndpoint: https://laconicd-mainnet-1.laconic.com/
gqlEndpoint: https://laconicd-sapo.laconic.com/api gqlEndpoint: https://laconicd-mainnet-1.laconic.com/api
userKey: userKey:
bondId: bondId: 230cfedda15e78edc8986dfcb870e1b618f65c56e38d2735476d2a8cb3f25e38
chainId: laconic-testnet-2 chainId: laconic-mainnet
gasPrice: 0.001alnt gasPrice: 0.001alnt

View File

@ -6,7 +6,7 @@ echo "Using DEPLOYER_LRN: $DEPLOYER_LRN"
echo "Using AUTHORITY: $AUTHORITY" echo "Using AUTHORITY: $AUTHORITY"
# Repository URL # Repository URL
REPO_URL="https://git.vdb.to/cerc-io/snowballtools-base" REPO_URL="https://git.vdb.to/NasSharaf/laconic-deployer-frontend"
# Get the latest commit hash for a branch # Get the latest commit hash for a branch
BRANCH_NAME="main" BRANCH_NAME="main"
@ -25,7 +25,7 @@ CONFIG_FILE=config.yml
# Reference: https://git.vdb.to/cerc-io/test-progressive-web-app/src/branch/main/scripts # Reference: https://git.vdb.to/cerc-io/test-progressive-web-app/src/branch/main/scripts
# Get latest version from registry and increment application-record version # Get latest version from registry and increment application-record version
NEW_APPLICATION_VERSION=$(pnpm --silent laconic -c $CONFIG_FILE registry record list --type ApplicationRecord --all --name "deploy-frontend" 2>/dev/null | jq -r -s ".[] | sort_by(.createTime) | reverse | [ .[] | select(.bondId == \"$REGISTRY_BOND_ID\") ] | .[0].attributes.version" | awk -F. -v OFS=. '{$NF += 1 ; print}') NEW_APPLICATION_VERSION=$(pnpm --silent laconic -c $CONFIG_FILE registry record list --type ApplicationRecord --all --name "laconic-deployer-frontend" 2>/dev/null | jq -r -s ".[] | sort_by(.createTime) | reverse | [ .[] | select(.bondId == \"$REGISTRY_BOND_ID\") ] | .[0].attributes.version" | awk -F. -v OFS=. '{$NF += 1 ; print}')
if [ -z "$NEW_APPLICATION_VERSION" ] || [ "1" == "$NEW_APPLICATION_VERSION" ]; then if [ -z "$NEW_APPLICATION_VERSION" ] || [ "1" == "$NEW_APPLICATION_VERSION" ]; then
# Set application-record version if no previous records were found # Set application-record version if no previous records were found
@ -39,8 +39,8 @@ record:
version: $NEW_APPLICATION_VERSION version: $NEW_APPLICATION_VERSION
repository_ref: $LATEST_HASH repository_ref: $LATEST_HASH
repository: ["$REPO_URL"] repository: ["$REPO_URL"]
app_type: webapp app_type: webapp/next
name: deploy-frontend name: laconic-deployer-frontend
app_version: $PACKAGE_VERSION app_version: $PACKAGE_VERSION
EOF EOF
@ -60,7 +60,7 @@ echo "ApplicationRecord published"
echo $RECORD_ID echo $RECORD_ID
# Set name to record # Set name to record
REGISTRY_APP_LRN="lrn://$AUTHORITY/applications/deploy-frontend" REGISTRY_APP_LRN="lrn://$AUTHORITY/applications/laconic-deployer-frontend"
sleep 2 sleep 2
pnpm --silent laconic -c $CONFIG_FILE registry name set "$REGISTRY_APP_LRN@${PACKAGE_VERSION}" "$RECORD_ID" pnpm --silent laconic -c $CONFIG_FILE registry name set "$REGISTRY_APP_LRN@${PACKAGE_VERSION}" "$RECORD_ID"
@ -122,19 +122,18 @@ cat >./records/application-deployment-request.yml <<EOF
record: record:
type: ApplicationDeploymentRequest type: ApplicationDeploymentRequest
version: '1.0.0' version: '1.0.0'
name: deploy-frontend@$PACKAGE_VERSION name: laconic-deployer-frontend@$PACKAGE_VERSION
application: lrn://$AUTHORITY/applications/deploy-frontend@$PACKAGE_VERSION application: lrn://$AUTHORITY/applications/laconic-deployer-frontend@$PACKAGE_VERSION
deployer: $DEPLOYER_LRN deployer: $DEPLOYER_LRN
dns: deploy.laconic.com dns: deploy-staging.laconic.com
config: config:
env: env:
LACONIC_HOSTED_CONFIG_server_url: https://deploy-backend.laconic.com NEXT_PUBLIC_WALLET_IFRAME_URL: https://wallet.laconic.com
LACONIC_HOSTED_CONFIG_github_clientid: Ov23li4NtYybQlF6u5Dk NEXT_PUBLIC_LACONICD_CHAIN_ID: laconic-mainnet
LACONIC_HOSTED_CONFIG_github_pwa_templaterepo: laconic-templates/test-progressive-web-app NEXT_PUBLIC_API_URL: https://deploy-backend.apps.vaasl.io
LACONIC_HOSTED_CONFIG_github_image_upload_templaterepo: laconic-templates/image-upload-pwa-example NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY:
LACONIC_HOSTED_CONFIG_github_next_app_templaterepo: laconic-templates/starter.nextjs-react-tailwind CLERK_SECRET_KEY:
LACONIC_HOSTED_CONFIG_laconicd_chain_id: laconic-testnet-2 NEXT_PUBLIC_GITHUB_FALLBACK_TOKEN: your_github_token
LACONIC_HOSTED_CONFIG_wallet_iframe_url: https://wallet.laconic.com
meta: meta:
note: Added @ $CURRENT_DATE_TIME note: Added @ $CURRENT_DATE_TIME
repository: "$REPO_URL" repository: "$REPO_URL"
@ -142,6 +141,12 @@ record:
payment: $txHash payment: $txHash
EOF EOF
# Old env values for frontend app
# LACONIC_HOSTED_CONFIG_github_clientid: Ov23li4NtYybQlF6u5Dk
# LACONIC_HOSTED_CONFIG_github_pwa_templaterepo: laconic-templates/test-progressive-web-app
# LACONIC_HOSTED_CONFIG_github_image_upload_templaterepo: laconic-templates/image-upload-pwa-example
# LACONIC_HOSTED_CONFIG_github_next_app_templaterepo: laconic-templates/starter.nextjs-react-tailwind
RECORD_FILE=records/application-deployment-request.yml RECORD_FILE=records/application-deployment-request.yml
sleep 2 sleep 2

16
build-webapp.sh Executable file
View File

@ -0,0 +1,16 @@
#!/bin/bash
PKG_DIR="./apps/deploy-fe"
cat > $PKG_DIR/.env <<EOF
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=CERC_RUNTIME_ENV_NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY
CLERK_SECRET_KEY=CERC_RUNTIME_ENV_CLERK_SECRET_KEY
NEXT_PUBLIC_WALLET_IFRAME_URL=CERC_RUNTIME_ENV_NEXT_PUBLIC_WALLET_IFRAME_URL
NEXT_PUBLIC_LACONICD_CHAIN_ID=CERC_RUNTIME_ENV_NEXT_PUBLIC_LACONICD_CHAIN_ID
NEXT_PUBLIC_API_URL=CERC_RUNTIME_ENV_NEXT_PUBLIC_API_URL
NEXT_PUBLIC_GITHUB_FALLBACK_TOKEN=CERC_RUNTIME_ENV_NEXT_PUBLIC_GITHUB_FALLBACK_TOKEN
EOF
pnpm install || exit 1
pnpm build:fe-compile || exit 1

View File

@ -5,6 +5,7 @@
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"build": "turbo build", "build": "turbo build",
"build:fe-compile": "turbo build:compile --filter=deploy-fe",
"dev": "turbo dev", "dev": "turbo dev",
"lint": "turbo lint", "lint": "turbo lint",
"start": "turbo start", "start": "turbo start",

2073
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -41,7 +41,7 @@ Create a `.env.local` file in the `apps/deploy-fe` directory:
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=your_clerk_key NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=your_clerk_key
CLERK_SECRET_KEY=your_clerk_secret CLERK_SECRET_KEY=your_clerk_secret
NEXT_PUBLIC_WALLET_IFRAME_URL=http://localhost:4000 NEXT_PUBLIC_WALLET_IFRAME_URL=http://localhost:4000
NEXT_PUBLIC_LACONICD_CHAIN_ID=laconic-testnet-2 NEXT_PUBLIC_LACONICD_CHAIN_ID=laconic-mainnet
NEXT_PUBLIC_API_URL=http://localhost:8000 NEXT_PUBLIC_API_URL=http://localhost:8000
NEXT_PUBLIC_GITHUB_FALLBACK_TOKEN=your_github_token NEXT_PUBLIC_GITHUB_FALLBACK_TOKEN=your_github_token
``` ```
@ -93,3 +93,25 @@ Make sure you have:
1. Connected your GitHub account in Clerk 1. Connected your GitHub account in Clerk
2. Provided the necessary permissions for repository access 2. Provided the necessary permissions for repository access
3. Set a fallback GitHub token in `.env.local` if needed for development 3. Set a fallback GitHub token in `.env.local` if needed for development
## Test deployment
- Follow the dev install steps for stack-orchestrator from <https://git.vdb.to/cerc-io/stack-orchestrator/src/branch/main/docs/CONTRIBUTING.md#install>
- Build the container for app
```bash
laconic-so build-webapp --source-repo <path-to-app-repo>/laconic-deployer-frontend --base-container cerc/nextjs-base
```
- Create an env file as described in [previous steps](#3-configure-environment-variables)
- Run the webapp container locally
```bash
docker run -p 3000:80 --env-file <path-to-env-file> cerc/laconic-deployer-frontend:local
```
- Scripts used for deployment
- App container build : [build-webapp.sh](./build-webapp.sh)
- Run app container : [run-webapp.sh](./run-webapp.sh)

17
run-webapp.sh Executable file
View File

@ -0,0 +1,17 @@
#!/usr/bin/env bash
if [ -n "$CERC_SCRIPT_DEBUG" ]; then
set -x
fi
SCRIPT_DIR=/scripts/
PKG_DIR="./apps/deploy-fe"
cd $PKG_DIR
"$SCRIPT_DIR/apply-runtime-env.sh" "`pwd`" .next .next-r
mv .next .next.old
mv .next-r/.next .
pnpm build:generate || exit 1
pnpm start -p ${CERC_LISTEN_PORT:-80}

View File

@ -8,6 +8,11 @@
"outputs": [".next/**", "!.next/cache/**"], "outputs": [".next/**", "!.next/cache/**"],
"env": ["NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY", "CLERK_SECRET_KEY"] "env": ["NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY", "CLERK_SECRET_KEY"]
}, },
"build:compile": {
"dependsOn": ["^build"],
"inputs": ["$TURBO_DEFAULT$"],
"outputs": ["dist/**"]
},
"start": { "start": {
"dependsOn": ["^build"], "dependsOn": ["^build"],
"cache": false, "cache": false,