- ), [currentTable, renderHeaderProjectPath, renderContentBodyComp])
+ ), [currentTable, renderHeaderProjectPath, renderContentBodyComp, currentProjectEnv, projects, databaseEnvironments])
return (
diff --git a/src/electron.js b/src/electron.js
index ceaeed4..bd9bdf5 100644
--- a/src/electron.js
+++ b/src/electron.js
@@ -21,6 +21,8 @@ const {
performQuery,
} = require('@spec.dev/app-db')
const url = require('url')
+const { useProject, getUserProjects, getCurrentProjectEnvs, useEnv, showEnv, showProject } = require('./services/crudEnv/projectAndEnv')
+
const { stringify, parse } = JSON
@@ -262,6 +264,13 @@ app.whenReady().then(() => {
ipcMain.handle('query', query)
ipcMain.handle('createSpecClient', createSpecClient)
ipcMain.handle('killSpecClient', killSpecClient)
+ ipcMain.handle('useProject', useProject)
+ ipcMain.handle('getProjects', getProjects)
+ ipcMain.handle('getUserProjects', getUserProjects)
+ ipcMain.handle('getCurrentProjectEnvs', getCurrentProjectEnvs)
+ ipcMain.handle('useEnv', useEnv)
+ ipcMain.handle('showProject', showProject)
+ ipcMain.handle('showEnv', showEnv)
createWindow()
})
diff --git a/src/preload.js b/src/preload.js
index 32c80af..2f89c19 100644
--- a/src/preload.js
+++ b/src/preload.js
@@ -10,6 +10,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
subscribeToPath: (...args) => ipcRenderer.invoke('subscribeToPath', ...args),
createSpecClient: (...args) => ipcRenderer.invoke('createSpecClient', ...args),
killSpecClient: () => ipcRenderer.invoke('killSpecClient'),
+ useProject: (...args) => ipcRenderer.invoke('useProject', ...args),
+ getProjects: (...args) => ipcRenderer.invoke('getProjects', ...args),
+ getUserProjects: (...args) => ipcRenderer.invoke('getUserProjects', ...args),
+ getCurrentProjectEnvs: (...args) => ipcRenderer.invoke('getCurrentProjectEnvs', ...args),
+ useEnv: (...args) => ipcRenderer.invoke('useEnv', ...args),
+ showProject: (...args) => ipcRenderer.invoke('showProject', ...args),
+ showEnv: (...args) => ipcRenderer.invoke('showEnv', ...args),
on: (...args) => ipcRenderer.on(...args),
off: (...args) => ipcRenderer.removeAllListeners(...args),
send: (...args) => ipcRenderer.send(...args),
diff --git a/src/services/crudEnv/constants.js b/src/services/crudEnv/constants.js
new file mode 100644
index 0000000..d70b823
--- /dev/null
+++ b/src/services/crudEnv/constants.js
@@ -0,0 +1,74 @@
+const path = require('path')
+const os = require('os')
+
+const ev = (name, fallback) =>
+ process.env.hasOwnProperty(name) ? process.env[name] : fallback
+
+const constants = {
+ // Spec project config.
+ SPEC_CONFIG_DIR_NAME: '.spec',
+ CONNECTION_CONFIG_FILE_NAME: 'connect.toml',
+ PROJECT_CONFIG_FILE_NAME: 'project.toml',
+ MIGRATIONS_DIR_NAME: 'migrations',
+ HANDLERS_DIR_NAME: 'handlers',
+ HOOKS_DIR_NAME: 'hooks',
+ GRAPHQL_DIR_NAME: 'graphql',
+
+ // Global CLI config.
+ SPEC_GLOBAL_DIR: path.join(os.homedir(), '.spec'),
+ SPEC_GLOBAL_STATE_FILE_NAME: 'state.toml',
+ SPEC_GLOBAL_CREDS_FILE_NAME: 'creds.toml',
+ SPEC_GLOBAL_PROJECTS_FILE_NAME: 'projects.toml',
+
+ // Spec base/ecosystem.
+ SPEC_ORIGIN: ev('SPEC_ORIGIN', 'https://spec.dev'),
+
+ // Spec API config.
+ SPEC_API_ORIGIN: ev('SPEC_API_ORIGIN', 'https://api.spec.dev'),
+ USER_AUTH_HEADER_NAME: 'Spec-User-Auth-Token',
+
+ // DB defaults.
+ SPEC_DB_USER: 'spec',
+ DB_PORT: 5432,
+
+ // Live Table testing.
+ LIVE_OBJECT_TESTING_DB_NAME: 'live-object-testing',
+ LIVE_OBJECT_TESTING_API_PORT: 8000,
+
+ // Desktop app name.
+ SPEC_APP_NAME: 'Spec',
+
+ // Default log size.
+ DEFAULT_LOG_TAIL_SIZE: 20,
+}
+
+constants.SPEC_CONFIG_DIR = path.join(process.cwd(), constants.SPEC_CONFIG_DIR_NAME)
+constants.CONNECTION_CONFIG_PATH = path.join(
+ constants.SPEC_CONFIG_DIR,
+ constants.CONNECTION_CONFIG_FILE_NAME
+)
+constants.PROJECT_CONFIG_PATH = path.join(
+ constants.SPEC_CONFIG_DIR,
+ constants.PROJECT_CONFIG_FILE_NAME
+)
+constants.SPEC_GLOBAL_STATE_PATH = path.join(
+ constants.SPEC_GLOBAL_DIR,
+ constants.SPEC_GLOBAL_STATE_FILE_NAME
+)
+constants.SPEC_GLOBAL_CREDS_PATH = path.join(
+ constants.SPEC_GLOBAL_DIR,
+ constants.SPEC_GLOBAL_CREDS_FILE_NAME
+)
+constants.SPEC_GLOBAL_PROJECTS_PATH = path.join(
+ constants.SPEC_GLOBAL_DIR,
+ constants.SPEC_GLOBAL_PROJECTS_FILE_NAME
+)
+constants.SPEC_GLOBAL_COMPOSE_DIR = path.join(constants.SPEC_GLOBAL_DIR, 'compose')
+constants.SPEC_HANDLERS_DIR = path.join(constants.SPEC_CONFIG_DIR, constants.HANDLERS_DIR_NAME)
+constants.SPEC_HOOKS_DIR = path.join(constants.SPEC_CONFIG_DIR, constants.HOOKS_DIR_NAME)
+
+
+module.exports = {
+ constants,
+ ev
+}
\ No newline at end of file
diff --git a/src/services/crudEnv/projectAndEnv.js b/src/services/crudEnv/projectAndEnv.js
new file mode 100644
index 0000000..a70c3cb
--- /dev/null
+++ b/src/services/crudEnv/projectAndEnv.js
@@ -0,0 +1,420 @@
+const fs = require('fs')
+const toml = require('@ltd/j-toml')
+const { constants } = require('./constants')
+const netrc = require('netrc')
+const fetch = require('node-fetch')
+const { routes } = require('./routes')
+const path = require('path')
+
+const createDir = (path) => fs.mkdirSync(path)
+
+const createFileWithContents = (path, contents) =>
+ fs.writeFileSync(path, contents)
+
+const fileExists = (path) => fs.existsSync(path)
+
+function readTomlConfigFile(path) {
+ if (!fileExists(path)) {
+ return { data: {} }
+ }
+ try {
+ const data = toml.parse(fs.readFileSync(path, 'utf-8'))
+ return { data }
+ } catch (error) {
+ return { error }
+ }
+}
+
+function saveTomlConfigFile(path, table) {
+ let error
+ try {
+ createFileWithContents(
+ path,
+ toml.stringify(table, { newlineAround: 'section', newline: '\n' })
+ )
+ } catch (err) {
+ error = err
+ }
+ return { error }
+}
+
+function saveGlobalStateFile(table) {
+ return saveTomlConfigFile(constants.SPEC_GLOBAL_STATE_PATH, table)
+}
+
+function readGlobalStateFile() {
+ return readTomlConfigFile(constants.SPEC_GLOBAL_STATE_PATH)
+}
+
+function saveState(updates) {
+ // Ensure spec global config directory exists.
+ upsertSpecGlobalDir()
+
+ // Get current global state.
+ const { data, error } = readGlobalStateFile()
+ if (error) return { error }
+
+ // Apply and save updates.
+ return saveGlobalStateFile({ ...data, ...updates })
+}
+
+function upsertSpecGlobalDir() {
+ fileExists(constants.SPEC_GLOBAL_DIR) || createDir(constants.SPEC_GLOBAL_DIR)
+}
+
+async function useEnv(_, env) {
+ /*
+ TODO:
+ ----
+ 1) Get the current project id from state.toml
+ 2) Resolve the full project from projects.toml
+ 3) Use the projects "location" to read connect.toml
+ 4) Validate the env exists in connect.toml before setting.
+ */
+ const { error: setEnvError } = saveState({ projectEnv: env })
+ if (setEnvError) {
+ console.error(setEnvError)
+ return
+ }
+
+}
+
+function readGlobalCredsFile() {
+ return readTomlConfigFile(constants.SPEC_GLOBAL_CREDS_PATH)
+}
+
+function saveGlobalCredsFile(table) {
+ saveTomlConfigFile(constants.SPEC_GLOBAL_CREDS_PATH, table)
+}
+
+const DEFAULT_PROJECT_ENV = 'local'
+
+function saveProjectCreds(
+ nsp,
+ name,
+ id,
+ apiKey
+) {
+ // Ensure spec global config directory exists.
+ upsertSpecGlobalDir()
+
+ // Get current global creds.
+ const { data, error } = readGlobalCredsFile()
+ if (error) return { error }
+
+ // Upsert project section within file.
+ const creds = data || {}
+ const projectPath = [nsp, name].join('/')
+ creds[projectPath] = creds[projectPath] || toml.Section({})
+ creds[projectPath].id = id
+ creds[projectPath].apiKey = apiKey
+
+ return saveGlobalCredsFile(creds)
+}
+
+function getCurrentProjectId() {
+ const { data, error } = readGlobalStateFile()
+ if (error) return { error }
+ return { data: data?.projectId || null }
+}
+
+function repoPathToComponents(path) {
+ const splitPath = path.split('/')
+ return splitPath.length === 2 ? splitPath : null
+}
+
+function getNetrcEntryId() {
+ const url = new URL(buildUrl(''))
+ return url.hostname
+}
+
+function getSessionToken() {
+ let token = null
+ let error = null
+ try {
+ const entries = netrc()
+ token = (entries[getNetrcEntryId()] || {}).password || null
+ } catch (err) {
+ error = err?.message || err
+ }
+ return { token, error }
+}
+
+async function get(
+ url,
+ args,
+ headers = undefined,
+ returnRawResponse = undefined
+) {
+ const params = new URLSearchParams()
+ for (let key in args) {
+ params.append(key, args[key])
+ }
+
+ let resp, err
+ try {
+ resp = await fetch(`${url}?${params.toString()}`, { headers })
+ } catch (err) {
+ err = err
+ }
+ if (err) return { error: err }
+
+ if (returnRawResponse) {
+ return { data: resp }
+ }
+
+ const { data, error } = await parseJSONResp(resp)
+ if (error) return { error }
+
+ return {
+ data,
+ headers: resp.headers,
+ }
+}
+
+async function parseJSONResp(resp) {
+ let data = {}
+ try {
+ data = await resp.json()
+ } catch (err) {
+ return { error: `Error parsing JSON response: ${err}.` }
+ }
+ if (data.error) {
+ return { error: data.error }
+ }
+ if (resp.status !== 200) {
+ return { error: `Request failed with status ${resp.status}.` }
+ }
+ return { data }
+}
+
+const formatAuthHeader = (sessionToken) => ({
+ [constants.USER_AUTH_HEADER_NAME]: sessionToken,
+})
+
+function removeTrailingSlash(str) {
+ return str.replace(/\/+$/, '')
+}
+
+const buildUrl = (route) => {
+ return [removeTrailingSlash(constants.SPEC_API_ORIGIN), route].join('/')
+}
+
+async function getUserProjects() {
+ const { token, error: tokenError } = getSessionToken()
+ const { data, error: dataError } = await get(
+ buildUrl(routes.GET_PROJECTS),
+ null,
+ formatAuthHeader(token)
+ )
+
+ if (dataError || tokenError) {
+ return []
+ } else {
+ const mappedData = data.map(project => {
+ return `${project?.namespace?.name}/${project?.slug}`
+ })
+ return mappedData
+ }
+
+}
+
+async function getProject(
+ namespace,
+ project,
+ sessionToken
+) {
+ // Perform link request.
+ const { data, error } = await get(
+ buildUrl(routes.GET_PROJECT),
+ { namespace, project },
+ formatAuthHeader(sessionToken)
+ )
+ if (error) return { error }
+
+ // Return project info.
+ return {
+ id: data.id || '',
+ name: data.slug || '',
+ namespace: data.namespace?.name || '',
+ apiKey: data.apiKey,
+ metadata: data.metadata || {},
+ }
+}
+
+function showProject() {
+ // Get current project id.
+ const { data: projectId, error } = getCurrentProjectId()
+ if (error) {
+ return
+ }
+ if (!projectId) {
+ return
+ }
+
+ // Get project info from global spec creds file.
+ const { data, error: infoError } = getProjectCreds(projectId)
+ if (infoError) {
+ return
+ }
+ if (!data?.path) {
+ return
+ }
+
+ return data.path
+}
+
+function getCurrentProjectEnv() {
+ const { data, error } = readGlobalStateFile()
+ if (error) return { error }
+ return { data: data?.projectEnv || null }
+}
+
+function showEnv() {
+ const { data: env, error } = getCurrentProjectEnv()
+ if (error) {
+ return
+ }
+
+ return env
+}
+
+async function useProject(_, projectPath, logResult = true) {
+ // Split input into namespace/project.
+ const pathComps = repoPathToComponents(projectPath)
+ if (!pathComps) {
+ console.warn('Please specify the project in
/ format.')
+ return
+ }
+ const [nsp, projectName] = pathComps
+
+
+ // Get authed user's session token (if any).
+ const { token, error } = getSessionToken()
+ if (error) {
+ console.error(error)
+ return
+ }
+ if (!token) {
+ console.error("Auth required")
+ return
+ }
+
+ // Resolve user's project by nsp/name.
+ const projectInfo = await getProject(nsp, projectName, token)
+ const {
+ id,
+ name,
+ namespace,
+ apiKey,
+ metadata,
+ } = projectInfo
+ if (projectInfo.error) {
+ console.error(`Failed to resolve project ${projectPath}: ${projectInfo.error}`)
+ return
+ }
+ if (!id || !name || !namespace || !apiKey) {
+ console.error(
+ `Failed to resolve project with ${projectPath}.\n
+ Couldn't resolve all necessary project attributes:\n
+ id=${id}\n
+ name=${name}\n
+ namespace=${namespace}\n
+ apiKey=${apiKey}`
+ )
+ return
+ }
+
+ // Save project id and api key to global creds file.
+ const result = saveProjectCreds(namespace, name, id, apiKey)
+ if (result && result.error) {
+ console.error(result.error)
+ return
+ }
+
+ // Set current project id in global state.
+ const { error: setProjectIdError } = saveState({
+ projectId: id,
+ projectEnv: DEFAULT_PROJECT_ENV,
+ })
+ if (setProjectIdError) {
+ console.error(setProjectIdError)
+ return
+ }
+
+ return { id, metadata }
+}
+
+function toMap(obj) {
+ const newObj = {}
+ for (let key in obj) {
+ newObj[key] = obj[key]
+ }
+ return newObj
+}
+
+function getProjectCreds(projectId){
+ const { data, error } = readGlobalCredsFile()
+ if (error) return { error }
+
+ const creds = toMap(data || {})
+ for (const key in creds) {
+ const projectCreds = creds[key]
+ if (projectCreds.id === projectId) {
+ return { data: { ...projectCreds, path: key } }
+ }
+ }
+ return { data: null }
+}
+
+function readGlobalProjectsFile() {
+ return readTomlConfigFile(constants.SPEC_GLOBAL_PROJECTS_PATH)
+}
+
+function getDBConfig(projectDirPath, projectEnv) {
+ const connectFilePath = path.join(
+ projectDirPath,
+ constants.SPEC_CONFIG_DIR_NAME,
+ constants.CONNECTION_CONFIG_FILE_NAME
+ )
+
+ // Ensure connection config file exists.
+ if (!fileExists(connectFilePath)) {
+ return { error: null }
+ }
+
+ // Return config for given environment.
+ try {
+ const data = toml.parse(fs.readFileSync(connectFilePath, 'utf-8')) || {}
+ return { data }
+ } catch (error) {
+ return { error }
+ }
+}
+
+async function getCurrentProjectEnvs(_, projectPath) {
+ if (!projectPath) {
+ return;
+ }
+ const { data: stateData, error: stateError } = readGlobalStateFile()
+ const { data: globalData, error: globalError } = readGlobalProjectsFile()
+ if (globalError || stateError) {
+ return []
+ }
+ const env = stateData?.projectEnv
+ const location = globalData[projectPath].location
+ const { data: dbData, error: dbError } = getDBConfig(location, env)
+ if (dbError) {
+ return []
+ }
+ return Object.keys(dbData) || []
+}
+
+module.exports = {
+ useProject,
+ useEnv,
+ getUserProjects,
+ getCurrentProjectEnvs,
+ showProject,
+ showEnv
+}
\ No newline at end of file
diff --git a/src/services/crudEnv/routes.js b/src/services/crudEnv/routes.js
new file mode 100644
index 0000000..c734853
--- /dev/null
+++ b/src/services/crudEnv/routes.js
@@ -0,0 +1,32 @@
+const prefix = {
+ USER: 'user',
+ PROJECT: 'project',
+ DEPLOYMENT: 'deployment',
+ CONTRACT_INSTANCES: 'contract-instances',
+ CONTRACT_REGISTRATION_JOB: 'contract-registration-job',
+ CONTRACT: 'contract',
+ ABI: 'abi',
+ EVENT_VERSION: 'event-version',
+ LIVE_OBJECT_VERSION: 'live-object-version',
+}
+
+const routes = {
+ LOGIN: [prefix.USER, 'login'].join('/'),
+ GET_PROJECT: [prefix.PROJECT, 'with-key'].join('/'),
+ GET_PROJECTS: [prefix.USER, 'projects'].join('/'),
+ CREATE_DEPLOYMENT: [prefix.DEPLOYMENT].join('/'),
+ PROJECT_LOGS: [prefix.PROJECT, 'logs'].join('/'),
+ GET_ABI: prefix.ABI,
+ REGISTER_CONTRACTS: [prefix.CONTRACT_INSTANCES, 'register'].join('/'),
+ GET_CONTRACT_REGISTRATION_JOB: prefix.CONTRACT_REGISTRATION_JOB,
+ CREATE_CONTRACT_GROUP: [prefix.CONTRACT, 'group'].join('/'),
+ GET_CONTRACT_GROUP: [prefix.CONTRACT, 'group'].join('/'),
+ GET_CONTRACT_GROUP_EVENTS: [prefix.CONTRACT, 'group', 'events'].join('/'),
+ RESOLVE_EVENT_VERSION_CURSORS: [prefix.EVENT_VERSION + 's', 'resolve', 'cursors'].join('/'),
+ GET_EVENT_VERSION_DATA_AFTER: [prefix.EVENT_VERSION + 's', 'data', 'after'].join('/'),
+ GET_LIVE_OBJECT_VERSION: prefix.LIVE_OBJECT_VERSION,
+}
+
+module.exports = {
+ routes
+}
\ No newline at end of file
diff --git a/src/styles/scss/dashboard-page.scss b/src/styles/scss/dashboard-page.scss
index ba7e98f..f21bda2 100644
--- a/src/styles/scss/dashboard-page.scss
+++ b/src/styles/scss/dashboard-page.scss
@@ -219,6 +219,24 @@
.dashboard__content-header-left {
display: inline-block;
+ padding-top: 12px;
+ display: flex;
+ flex-direction: row;
+ gap: 8px;
+}
+
+.dashboard__content-header-select {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: flex-start;
+ height: 100%;
+ padding-right: 4px;
+}
+
+.dashboard__col-input-field {
+ width: 144px;
+ height: 35px;
}
.dashboard__content-header-right {