Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions docs/organizations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
`dvc organizations`
===================

List the keys of all organizations available to the current user

* [`dvc organizations list`](#dvc-organizations-list)

## `dvc organizations list`

List the keys of all organizations available to the current user

```
USAGE
$ dvc organizations list [--config-path <value>] [--auth-path <value>] [--repo-config-path <value>] [--client-id
<value>] [--client-secret <value>] [--project <value>] [--no-api] [--headless]

GLOBAL FLAGS
--auth-path=<value> Override the default location to look for an auth.yml file
--client-id=<value> Client ID to use for DevCycle API Authorization
--client-secret=<value> Client Secret to use for DevCycle API Authorization
--config-path=<value> Override the default location to look for the user.yml file
--headless Disable all interactive flows and format output for easy parsing.
--no-api Disable API-based enhancements for commands where authorization is optional. Suppresses
warnings about missing credentials.
--project=<value> Project key to use for the DevCycle API requests
--repo-config-path=<value> Override the default location to look for the repo config.yml file

DESCRIPTION
List the keys of all organizations available to the current user
```
87 changes: 86 additions & 1 deletion oclif.manifest.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"version": "4.7.0",
"version": "4.7.1",
"commands": {
"authCommand": {
"id": "authCommand",
Expand Down Expand Up @@ -2350,6 +2350,91 @@
},
"args": {}
},
"organizations:list": {
"id": "organizations:list",
"description": "List the keys of all organizations available to the current user",
"strict": true,
"pluginName": "@devcycle/cli",
"pluginAlias": "@devcycle/cli",
"pluginType": "core",
"hidden": false,
"aliases": [],
"flags": {
"config-path": {
"name": "config-path",
"type": "option",
"description": "Override the default location to look for the user.yml file",
"helpGroup": "Global",
"multiple": false
},
"auth-path": {
"name": "auth-path",
"type": "option",
"description": "Override the default location to look for an auth.yml file",
"helpGroup": "Global",
"multiple": false
},
"repo-config-path": {
"name": "repo-config-path",
"type": "option",
"description": "Override the default location to look for the repo config.yml file",
"helpGroup": "Global",
"multiple": false
},
"client-id": {
"name": "client-id",
"type": "option",
"description": "Client ID to use for DevCycle API Authorization",
"helpGroup": "Global",
"multiple": false
},
"client-secret": {
"name": "client-secret",
"type": "option",
"description": "Client Secret to use for DevCycle API Authorization",
"helpGroup": "Global",
"multiple": false
},
"project": {
"name": "project",
"type": "option",
"description": "Project key to use for the DevCycle API requests",
"helpGroup": "Global",
"multiple": false
},
"no-api": {
"name": "no-api",
"type": "boolean",
"description": "Disable API-based enhancements for commands where authorization is optional. Suppresses warnings about missing credentials.",
"helpGroup": "Global",
"allowNo": false
},
"headless": {
"name": "headless",
"type": "boolean",
"description": "Disable all interactive flows and format output for easy parsing.",
"helpGroup": "Global",
"allowNo": false
},
"caller": {
"name": "caller",
"type": "option",
"description": "The integration that is calling the CLI.",
"hidden": true,
"helpGroup": "Global",
"multiple": false,
"options": [
"github.pr_insights",
"github.code_usages",
"bitbucket.pr_insights",
"bitbucket.code_usages",
"cli",
"vs_code_extension"
]
}
},
"args": {}
},
"projects:create": {
"id": "projects:create",
"description": "Create a new Project",
Expand Down
32 changes: 30 additions & 2 deletions src/auth/ApiAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { AuthConfig, storeAccessToken } from './config'
import { reportValidationErrors } from '../utils/reportValidationErrors'
import { AUTH_URL } from '../api/common'
import { TokenCache } from './TokenCache'
import { shouldRefreshToken } from './utils'
import { getTokenExpiry, shouldRefreshToken } from './utils'
import { CLI_CLIENT_ID } from './SSOAuth'

type SupportedFlags = {
Expand Down Expand Up @@ -43,11 +43,24 @@ export class ApiAuth {
return ''
}

private async getTokenFromAuthFile(): Promise<string> {
public getPersonalToken(): string {
if (this.authPath && fs.existsSync(this.authPath)) {
return this.getPersonalTokenFromAuthFile()
}

return ''
}

private loadAuthFile(): AuthConfig {
const rawConfig = jsYaml.load(fs.readFileSync(this.authPath, 'utf8'))
const config = plainToClass(AuthConfig, rawConfig)
const errors = validateSync(config)
reportValidationErrors(errors)
return config
}

private async getTokenFromAuthFile(): Promise<string> {
const config = this.loadAuthFile()

if (config.sso) {
const { accessToken, refreshToken } = config.sso
Expand All @@ -65,6 +78,21 @@ export class ApiAuth {
return ''
}

private getPersonalTokenFromAuthFile(): string {
const config = this.loadAuthFile()

if (config.sso?.personalAccessToken) {
const { personalAccessToken } = config.sso

const tokenExpiry = getTokenExpiry(personalAccessToken)
if (tokenExpiry && tokenExpiry > Date.now()) {
return personalAccessToken
}
}

return ''
}

private async fetchClientToken(client_id: string, client_secret: string): Promise<string> {
const cachedToken = this.tokenCache.get(client_id, client_secret)
if (cachedToken) {
Expand Down
20 changes: 15 additions & 5 deletions src/auth/SSOAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import axios from 'axios'
import { Organization } from '../api/organizations'
import Writer from '../ui/writer'
import { toggleBotSadSvg, toggleBotSvg } from '../ui/togglebot'
import { storeAccessToken } from './config'

export const CLI_CLIENT_ID = 'Ev9J0DGxR3KhrKaZwY6jlccmjl7JGKEX'

Expand Down Expand Up @@ -37,6 +38,7 @@ type OauthParams = {
type TokenResponse = {
accessToken: string
refreshToken: string
personalAccessToken?: string
}

export default class SSOAuth {
Expand All @@ -46,13 +48,17 @@ export default class SSOAuth {
private codeVerifier: string
private tokens: TokenResponse | undefined
private writer: Writer
private authPath: string

constructor(writer: Writer) {
constructor(writer: Writer, authPath: string) {
this.writer = writer
this.authPath = authPath
}

public async getAccessToken(organization: Organization | null = null): Promise<TokenResponse> {
this.organization = organization
public async getAccessToken(): Promise<Required<TokenResponse>>
public async getAccessToken(organization: Organization): Promise<Omit<TokenResponse, 'personalAccessToken'>>
public async getAccessToken(organization?: Organization): Promise<TokenResponse> {
if (organization) this.organization = organization
this.startLocalServer()
await this.waitForServerClosed()
return this.waitForToken()
Expand Down Expand Up @@ -144,11 +150,15 @@ export default class SSOAuth {
})

this.server.close()

if (response?.data) {
const { access_token, refresh_token } = response.data
this.tokens = {
accessToken: response.data.access_token,
refreshToken: response.data.refresh_token,
accessToken: access_token,
refreshToken: refresh_token,
personalAccessToken: this.organization ? undefined : access_token
}
storeAccessToken(this.tokens, this.authPath)
}
if (this.organization) {
this.writer.successMessage(`Access token retrieved for "${this.organization.display_name}" organization`)
Expand Down
12 changes: 10 additions & 2 deletions src/auth/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ export class SSOAuthConfig {
@IsString()
@IsOptional()
refreshToken?: string

@IsString()
@IsOptional()
personalAccessToken?: string
}

export class AuthConfig {
Expand All @@ -37,12 +41,16 @@ export class AuthConfig {
sso?: SSOAuthConfig
}

export function storeAccessToken(tokens: SSOAuthConfig, authPath: string): void {
export function storeAccessToken(tokens: Partial<SSOAuthConfig>, authPath: string): void {
const configDir = path.dirname(authPath)
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true })
}
const config = new AuthConfig()
config.sso = tokens
config.sso = config.sso || new SSOAuthConfig()
if (tokens.accessToken) config.sso.accessToken = tokens.accessToken
if (tokens.refreshToken) config.sso.refreshToken = tokens.refreshToken
if (tokens.personalAccessToken) config.sso.personalAccessToken = tokens.personalAccessToken

fs.writeFileSync(authPath, jsYaml.dump(config))
}
5 changes: 2 additions & 3 deletions src/commands/authCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { Flags } from '@oclif/core'
import { fetchOrganizations, Organization } from '../api/organizations'
import { fetchProjects } from '../api/projects'
import SSOAuth from '../auth/SSOAuth'
import { storeAccessToken } from '../auth/config'
import { promptForOrganization } from '../ui/promptForOrganization'
import { promptForProject } from '../ui/promptForProject'
import Base from './base'
Expand Down Expand Up @@ -100,10 +99,10 @@ export default abstract class AuthCommand extends Base {
}

async selectOrganization(organization: Organization): Promise<string> {
const ssoAuth = new SSOAuth(this.writer)
const ssoAuth = new SSOAuth(this.writer, this.authPath)
const tokens = await ssoAuth.getAccessToken(organization)
const { id, name, display_name } = organization
storeAccessToken(tokens, this.authPath)

if (this.repoConfig) {
this.updateRepoConfig({ org: { id, name, display_name } })
} else if (this.userConfig) {
Expand Down
15 changes: 14 additions & 1 deletion src/commands/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { Prompt, handleCustomPrompts } from '../ui/prompts'
import { filterPrompts, mergeFlagsAndAnswers } from '../utils/prompts'
import z, { ZodObject, ZodTypeAny, ZodError } from 'zod'
import { getTokenExpiry } from '../auth/utils'
import SSOAuth from '../auth/SSOAuth'

export default abstract class Base extends Command {
static hidden = true
Expand Down Expand Up @@ -71,6 +72,7 @@ export default abstract class Base extends Command {
}

authToken = ''
personalAccessToken = ''
projectKey = ''
authPath = path.join(this.config.configDir, 'auth.yml')
configPath = path.join(this.config.configDir, 'user.yml')
Expand All @@ -82,6 +84,8 @@ export default abstract class Base extends Command {
authRequired = false
// Override to true in commands that have "enhanced" functionality enabled by API access
authSuggested = false
// Override to true in commands that must be authorized by a user in order to function
userAuthRequired = false
// Override to true in commands that expect to run in the repo
runsInRepo = false

Expand All @@ -98,7 +102,16 @@ export default abstract class Base extends Command {
}
private async authorizeApi(): Promise<void> {
const { flags } = await this.parse(this.constructor as typeof Base)
this.authToken = await new ApiAuth(this.authPath, this.config.cacheDir).getToken(flags)
const auth = new ApiAuth(this.authPath, this.config.cacheDir)
this.authToken = await auth.getToken(flags)
this.personalAccessToken = auth.getPersonalToken()

if (!this.personalAccessToken && this.userAuthRequired) {
const ssoAuth = new SSOAuth(this.writer, this.authPath)
const tokens = await ssoAuth.getAccessToken()
this.personalAccessToken = tokens.personalAccessToken
}

if (!this.hasToken()) {
if (this.authRequired) {
throw new Error(
Expand Down
2 changes: 1 addition & 1 deletion src/commands/login/sso.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export default class LoginSSO extends AuthCommand {
static examples = []

public async run(): Promise<void> {
const ssoAuth = new SSOAuth(this.writer)
const ssoAuth = new SSOAuth(this.writer, this.authPath)
const tokens = await ssoAuth.getAccessToken()
this.authToken = tokens.accessToken
await this.setOrganizationAndProject()
Expand Down
14 changes: 14 additions & 0 deletions src/commands/organizations/list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { fetchOrganizations } from '../../api/organizations'
import Base from '../base'

export default class ListOrganizations extends Base {
static description = 'List the keys of all organizations available to the current user'
static hidden = false

userAuthRequired = true

public async run(): Promise<void> {
const orgs = await fetchOrganizations(this.personalAccessToken)
return this.writer.showResults(orgs.map((orgs) => orgs.name))
}
}
4 changes: 1 addition & 3 deletions src/commands/repo/init.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import 'reflect-metadata'

import { storeAccessToken } from '../../auth/config'
import SSOAuth from '../../auth/SSOAuth'
import AuthCommand from '../authCommand'

Expand All @@ -16,10 +15,9 @@ export default class InitRepo extends AuthCommand {

this.repoConfig = await this.updateRepoConfig({})

const ssoAuth = new SSOAuth(this.writer)
const ssoAuth = new SSOAuth(this.writer, this.authPath)
const tokens = await ssoAuth.getAccessToken()
this.authToken = tokens.accessToken
storeAccessToken(tokens, this.authPath)

await this.setOrganizationAndProject()
}
Expand Down