Skip to content

Commit 68c0993

Browse files
authored
add hyp link (#24)
* add initial hyp link logic * . * lots of fixes * add server logic * . * . * finish
1 parent b0d04a1 commit 68c0993

File tree

8 files changed

+511
-33
lines changed

8 files changed

+511
-33
lines changed

src/commands/link/index.ts

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
import {Command} from '@oclif/core'
2+
import chalk from 'chalk'
3+
import * as fs from 'node:fs'
4+
import * as http from 'node:http'
5+
import {URL} from 'node:url'
6+
import open from 'open'
7+
8+
import {ciStr} from '../../util/ci.js'
9+
import {
10+
getProjectsByOrgReq, sendCreateProjectRepoReq, sendCreateProjectReq, sendGetRepoIdReq,
11+
} from '../../util/graphql.js'
12+
import {
13+
confirmExistingProjectLink, confirmOverwriteCiHypFile, fileExists, getCiHypFilePath, getEnvFilePath, getGitConfigFilePath,
14+
getGitRemoteUrl, getGithubWorkflowDir, promptProjectLinkSelection, promptProjectName, readSettingsJson,
15+
} from '../../util/index.js'
16+
17+
export default class LinkIndex extends Command {
18+
static override args = {}
19+
20+
static override description = 'Link a Modus App To Hypermode'
21+
22+
static override examples = [
23+
'<%= config.bin %> <%= command.id %>',
24+
]
25+
26+
static override flags = {}
27+
28+
public async getUserInstallationThroughAuthFlow(): Promise<string> {
29+
return new Promise((resolve, reject) => {
30+
const server = http.createServer(async (req, res) => {
31+
try {
32+
const url = new URL(req.url ?? '', `http://${req.headers.host}`)
33+
const installationId = url.searchParams.get('install_id')
34+
35+
if (!installationId) {
36+
res.writeHead(400, {'Content-Type': 'text/plain'})
37+
res.end('Installation ID not found in the request.')
38+
return
39+
}
40+
41+
res.writeHead(200, {'Content-Type': 'text/html'})
42+
res.end(linkHTML)
43+
44+
// Close all existing connections
45+
server.closeAllConnections()
46+
47+
// Close the server and wait for it to actually close
48+
server.close(async err => {
49+
if (err) {
50+
reject(err)
51+
return
52+
}
53+
54+
resolve(installationId)
55+
})
56+
} catch (error) {
57+
res.writeHead(500, {'Content-Type': 'text/plain'})
58+
res.end('An error occurred during authentication.')
59+
reject(error)
60+
}
61+
})
62+
63+
// Set a timeout for the server
64+
const timeoutDuration = 300_000 // 300 seconds in milliseconds
65+
const timeout = setTimeout(() => {
66+
server.closeAllConnections()
67+
server.close()
68+
reject(new Error('Authentication timed out. Please try again.'))
69+
}, timeoutDuration)
70+
71+
// Listen on port 5051 for the redirect
72+
server.listen(5051, 'localhost', async () => {
73+
try {
74+
this.log('Opening link page...')
75+
await this.openLinkPage()
76+
} catch (error) {
77+
server.close()
78+
reject(error)
79+
}
80+
})
81+
82+
// Ensure the timeout is cleared if the server closes successfully
83+
server.on('close', () => {
84+
clearTimeout(timeout)
85+
})
86+
87+
// Handle server errors
88+
server.on('error', error => {
89+
clearTimeout(timeout)
90+
reject(error)
91+
})
92+
})
93+
}
94+
95+
public async openLinkPage() {
96+
// Open the Hypermode sign-in page in the default browser
97+
const state = encodeURIComponent(JSON.stringify({p: 5051, s: 'cli'}))
98+
const linkUrl = 'https://github.com/apps/hypermode/installations/new?state=' + state
99+
await open(linkUrl)
100+
}
101+
102+
public async run(): Promise<void> {
103+
// check if the directory has a .git/config with a remote named 'origin', if not, throw an error and ask them to set that up
104+
const gitConfigFilePath = getGitConfigFilePath()
105+
106+
if (!fileExists(gitConfigFilePath)) {
107+
throw new Error('No remote git repository found')
108+
}
109+
110+
const gitUrl = getGitRemoteUrl(gitConfigFilePath)
111+
112+
// check the .hypermode/settings.json and see if there is a installationId with a key for the github owner. if there is,
113+
// continue, if not send them to github app installation page, and then go to callback server, and add installation id to settings.json
114+
115+
const envFilePath = getEnvFilePath()
116+
if (!fileExists(envFilePath)) {
117+
this.log(chalk.red('Not logged in.') + ' Log in with `hyp login`.')
118+
return
119+
}
120+
121+
const settings = readSettingsJson(envFilePath)
122+
123+
if (!settings.email || !settings.jwt || !settings.orgId) {
124+
this.log(chalk.red('Not logged in.') + ' Log in with `hyp login`.')
125+
return
126+
}
127+
128+
const gitOwner = gitUrl.split('/')[3]
129+
130+
const repoName = gitUrl.split('/')[4].replace(/\.git$/, '')
131+
132+
const installationId = (!settings.installationIds || !settings.installationIds[gitOwner]) ? await this.getUserInstallationThroughAuthFlow() : settings.installationIds[gitOwner]
133+
134+
// call hypermode getRepoId with the installationId and the git url, if it returns a repoId, continue, if not, throw an error
135+
const repoId = await sendGetRepoIdReq(settings.jwt, installationId, gitUrl)
136+
137+
if (!repoId) {
138+
throw new Error('No repoId found for the given installationId and gitUrl')
139+
}
140+
141+
// get list of the projects for the user in this org, if any have no repoId, ask if they want to link it, or give option of none.
142+
// If they pick an option, connect repo. If none, ask if they want to create a new project, prompt for name, and connect repoId to project
143+
const projects = await getProjectsByOrgReq(settings.jwt, settings.orgId)
144+
145+
const projectsNoRepoId = projects.filter(project => !project.repoId)
146+
147+
let selectedProject = null
148+
149+
if (projectsNoRepoId.length > 0) {
150+
const confirmExistingProject = await confirmExistingProjectLink()
151+
152+
if (confirmExistingProject) {
153+
selectedProject = await promptProjectLinkSelection(projectsNoRepoId)
154+
const completedProject = await sendCreateProjectRepoReq(settings.jwt, selectedProject.id, repoId, repoName)
155+
156+
this.log(chalk.green('Successfully linked project ' + completedProject.name + ' to repo ' + repoName + '! 🎉'))
157+
} else {
158+
const projectName = await promptProjectName(projects)
159+
const newProject = await sendCreateProjectReq(settings.jwt, settings.orgId, projectName, repoId, repoName)
160+
161+
this.log(chalk.green('Successfully created project ' + newProject.name + ' and linked it to repo ' + repoName + '! 🎉'))
162+
}
163+
} else {
164+
const projectName = await promptProjectName(projects)
165+
const newProject = await sendCreateProjectReq(settings.jwt, settings.orgId, projectName, repoId, repoName)
166+
167+
this.log(chalk.green('Successfully created project ' + newProject.name + ' and linked it to repo ' + repoName + '! 🎉'))
168+
}
169+
170+
// add ci workflow to the repo if it doesn't already exist
171+
const githubWorkflowDir = getGithubWorkflowDir()
172+
const ciHypFilePath = getCiHypFilePath()
173+
174+
if (!fileExists(githubWorkflowDir)) {
175+
// create the directory
176+
fs.mkdirSync(githubWorkflowDir, {recursive: true})
177+
}
178+
179+
if (fileExists(ciHypFilePath)) {
180+
// prompt if they want to replace it
181+
const confirmOverwrite = await confirmOverwriteCiHypFile()
182+
if (!confirmOverwrite) {
183+
this.log(chalk.yellow('Skipping ci-hyp.yml creation.'))
184+
}
185+
}
186+
187+
// create the file
188+
fs.writeFileSync(ciHypFilePath, ciStr, {flag: 'w'})
189+
}
190+
}
191+
192+
const linkHTML = `<!-- src/commands/login/login.html -->
193+
<!DOCTYPE html>
194+
<html lang="en">
195+
<head>
196+
<meta charset="UTF-8" />
197+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
198+
<title>Success!</title>
199+
<style>
200+
body {
201+
font-family: Arial, sans-serif;
202+
display: flex;
203+
flex-direction: column;
204+
justify-content: center;
205+
align-items: center;
206+
height: 100vh;
207+
width: 100vw;
208+
margin: 0;
209+
background-color: #14161f;
210+
}
211+
h1 {
212+
color: #fff;
213+
text-align: center;
214+
margin-bottom: 8px;
215+
}
216+
217+
p {
218+
color: #62646b;
219+
text-align: center;
220+
}
221+
222+
svg {
223+
width: 36px;
224+
height: 36px;
225+
margin-bottom: 16px;
226+
}
227+
</style>
228+
</head>
229+
<body>
230+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
231+
<path
232+
fillRule="evenodd"
233+
clipRule="evenodd"
234+
fill="#fff"
235+
d="M10.0173 0H2.64764L0 10.3598H7.36967L10.0173 0ZM2.91136 22.6282L6.0172 10.3599H14.1776L16.8252 0.00012207H24.1949L18.3248 22.9691H10.9551L14.1592 10.4317L2.91136 22.6282Z"
236+
/>
237+
</svg>
238+
<h1>Link complete!</h1>
239+
<p>You can now close this window and return to the terminal.</p>
240+
</body>
241+
</html>
242+
`

src/commands/login/index.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@ import * as http from 'node:http'
55
import {URL} from 'node:url'
66
import open from 'open'
77

8+
import {sendGetOrgsReq} from '../../util/graphql.js'
89
import {
9-
fileExists, getEnvDir, getEnvFilePath, promptOrgSelection, sendGraphQLRequest,
10+
fileExists, getEnvDir, getEnvFilePath, promptOrgSelection, readSettingsJson,
1011
} from '../../util/index.js'
1112

1213
const loginHTML = `<!-- src/commands/login/login.html -->
@@ -104,7 +105,7 @@ export default class LoginIndex extends Command {
104105
}
105106

106107
try {
107-
const orgs = await sendGraphQLRequest(jwt)
108+
const orgs = await sendGetOrgsReq(jwt)
108109
const selectedOrg = await promptOrgSelection(orgs)
109110
this.writeToEnvFile(jwt, email, selectedOrg.id)
110111
this.log('Successfully logged in as ' + chalk.dim(email) + '! 🎉')
@@ -161,11 +162,14 @@ export default class LoginIndex extends Command {
161162
fs.mkdirSync(envDir, {recursive: true})
162163
}
163164

165+
const settings = readSettingsJson(envFilePath)
166+
164167
// Prepare the JSON object with the new content
165168
const newEnvContent = {
166169
HYP_EMAIL: email,
167170
HYP_JWT: jwt,
168171
HYP_ORG_ID: orgId,
172+
INSTALLATION_IDS: settings.installationIds,
169173
}
170174

171175
// Write the new content to the file, replacing any existing content

src/commands/logout/index.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,14 @@ export default class LogoutIndex extends Command {
3131

3232
console.log('Logging out of email: ' + chalk.dim(res.email))
3333

34+
const newEnvContent = {
35+
HYP_EMAIL: null,
36+
HYP_JWT: null,
37+
HYP_ORG_ID: null,
38+
INSTALLATION_IDS: res.installationIds,
39+
}
40+
3441
// remove all content from settings.json
35-
fs.writeFileSync(envFilePath, '{}', {flag: 'w'})
42+
fs.writeFileSync(envFilePath, JSON.stringify(newEnvContent, null, 2), {flag: 'w'})
3643
}
3744
}

src/commands/org/switch.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@ import {Command} from '@oclif/core'
22
import chalk from 'chalk'
33
import * as fs from 'node:fs'
44

5+
import {sendGetOrgsReq} from '../../util/graphql.js'
56
import {
6-
fileExists, getEnvFilePath, promptOrgSelection, readSettingsJson, sendGraphQLRequest,
7+
fileExists, getEnvFilePath, promptOrgSelection, readSettingsJson,
78
} from '../../util/index.js'
89

910
export default class OrgSwitch extends Command {
@@ -29,7 +30,7 @@ export default class OrgSwitch extends Command {
2930
return
3031
}
3132

32-
const orgs = await sendGraphQLRequest(res.jwt)
33+
const orgs = await sendGetOrgsReq(res.jwt)
3334
const selectedOrg = await promptOrgSelection(orgs)
3435

3536
const updatedContent = {

src/util/ci.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
export const ciStr = `
2+
name: ci-hypermode-functions
3+
4+
on:
5+
workflow_dispatch:
6+
pull_request:
7+
push:
8+
branches:
9+
- main
10+
11+
env:
12+
MODUS_DIR: ""
13+
14+
permissions:
15+
contents: read
16+
17+
jobs:
18+
build:
19+
name: build
20+
runs-on: ubuntu-latest
21+
steps:
22+
- name: Checkout code
23+
uses: actions/checkout@v4
24+
25+
- name: Locate directory with modus.json
26+
id: set-dir
27+
run: |
28+
MODUS_JSON=$(find $(pwd) -name 'modus.json' -print0 | xargs -0 -n1 echo)
29+
if [ -n "$MODUS_JSON" ]; then
30+
MODUS_DIR=$(dirname "$MODUS_JSON")
31+
echo "MODUS_DIR=$MODUS_DIR" >> $GITHUB_ENV
32+
else
33+
echo "modus.json not found"
34+
exit 1
35+
fi
36+
37+
- name: Setup Node
38+
uses: actions/setup-node@v4
39+
with:
40+
node-version: "22"
41+
42+
- name: Build project
43+
run: npx -p @hypermode/modus-cli -y modus build
44+
working-directory: \${{ env.MODUS_DIR }}
45+
shell: bash
46+
47+
- name: Publish GitHub artifact
48+
uses: actions/upload-artifact@v4
49+
with:
50+
name: build
51+
path: \${{ env.MODUS_DIR }}/build/*
52+
`

0 commit comments

Comments
 (0)