Skip to content
2 changes: 2 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Command } from 'commander'
import { registerConnect } from './commands/connect'
import { registerInit } from './commands/init'
import { registerPing } from './commands/ping'
import { VERSION } from './lib/constants'
Expand All @@ -11,6 +12,7 @@ program
.version(VERSION)

registerInit(program)
registerConnect(program)
registerPing(program)

program.parseAsync(process.argv)
37 changes: 37 additions & 0 deletions src/commands/connect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { Command } from 'commander'
import { readGithubApp } from '../lib/github_app'
import { currentDeploymentId } from '../lib/paths'
import { readState } from '../lib/state'

// `ellipsis init` is the wizard and the normal path. `connect` exists only to
// inspect or point at the right re-entry: connection steps read their inputs
// from install state, so they run inside the wizard, never from flags.
export function registerConnect(program: Command): void {
const connect = program.command('connect').description('Connection status for your install')

connect
.command('github')
.description('Show GitHub App connection status')
.action(() => {
const deploymentId = currentDeploymentId()
const state = readState()
if (!deploymentId || !state) {
console.log('No install in progress. Run `ellipsis init` to get started.')
process.exitCode = 1
return
}
const app = readGithubApp(deploymentId)
if (app) {
console.log(
`Connected: ${app.name} (app ${app.app_id}, owned by ${app.owner_login}).\n` +
`Manage it at ${app.html_url}.`,
)
} else {
console.log(
`Not connected. Run \`ellipsis init\` to continue — it will resume at the` +
` GitHub step for ${state.github_org}.`,
)
process.exitCode = 1
}
})
}
146 changes: 128 additions & 18 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import { execFile } from 'node:child_process'
import type { Command } from 'commander'
import { ApiError, registerInstall } from '../lib/api'
import { INSTALL_STEPS, renderChecklist } from '../lib/checklist'
import { VERSION } from '../lib/constants'
import { readCredentials, writeCredentials } from '../lib/credentials'
import { writeCredentials } from '../lib/credentials'
import { createAppViaManifest, writeGithubApp } from '../lib/github_app'
import { currentDeploymentId } from '../lib/paths'
import { ask, askYes, closePrompts, openPrompts } from '../lib/prompt'
import { readState, writeState, type InstallState } from '../lib/state'
import {
validateAwsAccountId,
validateCompany,
validateDeveloperCount,
validateDomain,
validateEmail,
validateGithubOrg,
} from '../lib/validate'
Expand Down Expand Up @@ -39,7 +44,7 @@ export function registerInit(program: Command): void {
await runInit()
} catch (err) {
if ((err as Error).message === 'stdin closed') {
console.error('\nInput ended before the wizard finished. Nothing was created.')
console.error('\nInput ended before the wizard finished.')
process.exitCode = 1
} else {
throw err
Expand All @@ -50,24 +55,55 @@ export function registerInit(program: Command): void {
})
}

// The wizard: fresh runs start at Step 1; every later `ellipsis init` resumes
// at the first incomplete step, reading answers from install state — a step
// never re-asks what an earlier step already learned.
async function runInit(): Promise<void> {
const existing = readCredentials()
if (existing) {
console.log(
`This machine already has an install credential (install ${existing.install_id},` +
` registered ${existing.registered_at}).\n` +
'Continuing would register a NEW install. Contact [email protected] if you need to reset.',
)
process.exitCode = 1
const deploymentId = currentDeploymentId()
const state = readState()

if (!deploymentId || !state) {
console.log(WELCOME)
console.log('Here is what we will do together:\n')
console.log(renderChecklist(0))
console.log()
await askYes('Are you ready to get started?')
const fresh = await stepStartTrial()
if (fresh) await continueFrom(fresh.deploymentId, fresh.state)
return
}

console.log(WELCOME)
console.log('Here is what we will do together:\n')
console.log(renderChecklist(0))
console.log(`\nWelcome back. Resuming your Ellipsis install for ${state.company}.\n`)
console.log(renderChecklist(state.completed_steps))
console.log()
await askYes('Are you ready to get started?')
if (state.completed_steps >= INSTALL_STEPS.length) {
console.log('Your install is complete.')
return
}
await continueFrom(deploymentId, state)
}

/** Run steps from the first incomplete one; stop at the first not-yet-built step. */
async function continueFrom(deploymentId: string, state: InstallState): Promise<void> {
let current = state
while (current.completed_steps < INSTALL_STEPS.length) {
const next = current.completed_steps // 0-indexed
await askYes(`Continue with Step ${next + 1} (${INSTALL_STEPS[next].title})?`)
switch (next) {
case 1:
current = await stepConnectGithub(deploymentId, current)
break
default:
console.log(
`\nStep ${next + 1} (${INSTALL_STEPS[next].title}) is not built yet — coming soon.`,
)
return
}
}
}

/** Step 1: collect identity, mint the trial, persist credential + state. */
async function stepStartTrial(): Promise<{ deploymentId: string; state: InstallState } | null> {
console.log(`\nStep 1: ${INSTALL_STEPS[0].title}\n`)

const email = await ask('What is your work email?', validateEmail)
Expand All @@ -80,6 +116,11 @@ async function runInit(): Promise<void> {
"What is the AWS Account ID you'd like to deploy Ellipsis in?",
validateAwsAccountId,
)
const domain = await ask(
'What domain will your Ellipsis installation use? (e.g. ellipsis.acme.com — you will' +
' delegate DNS to AWS during the deploy step; nothing needs to exist yet)',
validateDomain,
)
const githubOrg = await ask(
"What is the name of the GitHub organization you'd like to connect your self-hosted" +
' Ellipsis installation to? If your company has many GitHub organizations, just choose' +
Expand All @@ -97,6 +138,7 @@ async function runInit(): Promise<void> {
console.log()

console.log('Starting your free trial...')
let deploymentId: string
try {
const res = await registerInstall({
email,
Expand All @@ -106,14 +148,12 @@ async function runInit(): Promise<void> {
github_org: githubOrg,
cli_version: VERSION,
})
const path = writeCredentials({
deploymentId = res.install_id
writeCredentials({
install_id: res.install_id,
install_credential: res.install_credential,
registered_at: new Date().toISOString(),
})
console.log(`Trial active: 7 days. Credential saved to ${path}.\n`)
console.log(renderChecklist(1))
console.log('\nNext: `ellipsis init` will continue with Step 2 (coming soon).')
} catch (err) {
const reason =
err instanceof ApiError
Expand All @@ -124,5 +164,75 @@ async function runInit(): Promise<void> {
'Nothing was created. Please try again shortly, or contact [email protected].',
)
process.exitCode = 1
return null
}

const state: InstallState = {
email,
company,
developer_count: developerCount,
aws_account_id: awsAccountId,
github_org: githubOrg,
domain,
completed_steps: 1,
}
writeState(deploymentId, state)
console.log('Trial active: 7 days.\n')
console.log(renderChecklist(1))
console.log()
return { deploymentId, state }
}

function openBrowser(url: string): void {
const cmd =
process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open'
execFile(cmd, [url], (err) => {
if (err) console.log(`Could not open a browser automatically. Visit:\n ${url}`)
})
}

/** Step 2: create their GitHub App via the manifest flow. All inputs come from state. */
async function stepConnectGithub(deploymentId: string, state: InstallState): Promise<InstallState> {
const { github_org: org, domain } = state
const appName = `Ellipsis for ${org}`

console.log(
`\nStep 2: ${INSTALL_STEPS[1].title}\n\n` +
`We will create a GitHub App for your company:\n` +
` name: ${appName}\n` +
` owner: ${org}\n` +
` webhooks: https://api.${domain}/github/webhook\n\n` +
'Your browser will open a GitHub page showing the app and its permissions.\n' +
'One click there creates it; the credentials come back to this terminal directly\n' +
'and never pass through Ellipsis.\n',
)
await askYes('Ready?')

console.log('\nWaiting for you to click "Create GitHub App" in the browser...')
const app = await createAppViaManifest(
{
org,
appName,
webhookUrl: `https://api.${domain}/github/webhook`,
homepageUrl: `https://app.${domain}`,
},
{ openBrowser },
)
writeGithubApp(deploymentId, app)
console.log(`\nCreated ${app.name} (app ${app.app_id}, owned by ${app.owner_login}).`)
console.log(
'Credentials saved locally — the deploy step moves them into your AWS Secrets Manager.\n',
)

console.log(`Last part: install the app on ${org} and choose repositories.`)
const installUrl = `https://github.com/apps/${app.slug}/installations/new`
openBrowser(installUrl)
await askYes(`Done installing? (${installUrl})`)

const updated: InstallState = { ...state, completed_steps: 2 }
writeState(deploymentId, updated)
console.log()
console.log(renderChecklist(2))
console.log()
return updated
}
30 changes: 18 additions & 12 deletions src/lib/credentials.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,35 @@
import * as fs from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import {
currentDeploymentId,
readDeploymentFile,
setCurrentDeployment,
writeDeploymentFile,
} from './paths'

// The install credential issued by POST /v1/installs/register. Stored at
// ~/.ellipsis/credentials.json, chmod 600 — it authenticates every later call
// to license.ellipsis.dev for this install.
// The install credential issued by POST /v1/installs/register. It IS the
// deployment identity: writing it also points current-deployment at it.
// Lives at ~/.ellipsis/deployments/{id}/credentials.json, chmod 600.
export interface StoredCredentials {
install_id: string
install_credential: string
registered_at: string
}

const CREDENTIALS_DIR = path.join(os.homedir(), '.ellipsis')
const CREDENTIALS_PATH = path.join(CREDENTIALS_DIR, 'credentials.json')
const REL_PATH = 'credentials.json'

export function readCredentials(): StoredCredentials | null {
const id = currentDeploymentId()
if (!id) return null
const raw = readDeploymentFile(id, REL_PATH)
if (!raw) return null
try {
return JSON.parse(fs.readFileSync(CREDENTIALS_PATH, 'utf8')) as StoredCredentials
return JSON.parse(raw) as StoredCredentials
} catch {
return null
}
}

export function writeCredentials(creds: StoredCredentials): string {
fs.mkdirSync(CREDENTIALS_DIR, { recursive: true, mode: 0o700 })
fs.writeFileSync(CREDENTIALS_PATH, JSON.stringify(creds, null, 2) + '\n', { mode: 0o600 })
return CREDENTIALS_PATH
const path = writeDeploymentFile(creds.install_id, REL_PATH, JSON.stringify(creds, null, 2) + '\n')
setCurrentDeployment(creds.install_id)
return path
}
Loading