Skip to content

Commit 63121e2

Browse files
Store multiple access tokens for the same organization by using unique aliases. Refractor code to reduce complexity.
1 parent f34a4e6 commit 63121e2

2 files changed

Lines changed: 187 additions & 76 deletions

File tree

src/commands/account/login.ts

Lines changed: 117 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,22 @@ export default class AccountLogin extends ScCommand<typeof AccountLogin> {
1212
Stores organization credentials securely using OS keychain.
1313
The access token is encrypted and stored locally.
1414
15+
You can store multiple access tokens for the same organization by using unique aliases.
16+
Without an alias, only one token per organization ID is allowed.
17+
1518
Required token permissions: Varies by operations you intend to perform`
1619
static override examples = [
1720
'<%= config.bin %> <%= command.id %> --org=my-org-id',
1821
'<%= config.bin %> <%= command.id %> --org=my-org-id --alias=production',
22+
'<%= config.bin %> <%= command.id %> --org=my-org-id --alias=staging',
1923
'<%= config.bin %> <%= command.id %> --org=my-org-id --set-default',
2024
'<%= config.bin %> <%= command.id %> --org=my-org-id --no-prompt',
2125
'<%= config.bin %> <%= command.id %> --org=my-org-id --base-url=https://api.custom.solace.cloud',
2226
]
2327
static override flags = {
2428
'alias': Flags.string({
2529
char: 'a',
26-
description: 'Alias name for this organization (optional)',
30+
description: 'Alias name for this organization (allows storing multiple tokens for the same org with different aliases)',
2731
}),
2832
'api-version': Flags.string({
2933
description: 'API version to use (optional)',
@@ -51,105 +55,142 @@ Required token permissions: Varies by operations you intend to perform`
5155
const {flags} = await this.parse(AccountLogin)
5256

5357
try {
54-
// Get OrgManager instance
5558
const orgManager: OrgManager = await this.getOrgManager()
59+
const identifier = flags.alias ?? flags.org
5660

57-
// Check if organization already exists
58-
const orgExists = await orgManager.orgExists(flags.org)
59-
let isUpdate = false
60-
61-
if (orgExists) {
62-
// Prompt for overwrite confirmation
63-
const shouldOverwrite = await this.promptForConfirmation(
64-
`Organization '${flags.org}' already exists. Do you want to overwrite the access token?`,
65-
)
66-
67-
if (!shouldOverwrite) {
68-
this.log('Login cancelled.')
69-
this.exit(0)
70-
}
71-
72-
// Remove existing organization to allow overwrite
73-
await orgManager.removeOrg(flags.org)
74-
isUpdate = true
75-
}
61+
// Handle existing organization/alias
62+
const isUpdate = await this.handleExistingEntry(orgManager, identifier, flags.alias)
7663

7764
// Obtain access token
78-
let accessToken: string
79-
80-
if (flags['no-prompt']) {
81-
// Read from environment variable
82-
accessToken = process.env.SC_ACCESS_TOKEN || ''
83-
if (!accessToken) {
84-
this.error('SC_ACCESS_TOKEN environment variable is not set. Please set it or remove the --no-prompt flag.')
85-
}
86-
} else {
87-
// Prompt for token
88-
accessToken = await this.promptForToken()
89-
}
90-
91-
// Create OrgConfig object
92-
const orgConfig: OrgConfig = {
93-
accessToken,
94-
alias: flags.alias,
95-
apiVersion: flags['api-version'],
96-
baseUrl: flags['base-url'] ?? DEFAULT_BASE_URL,
97-
orgId: flags.org,
98-
}
65+
const accessToken = await this.obtainAccessToken(flags['no-prompt'])
9966

100-
// Store organization
67+
// Create and store organization config
68+
const orgConfig = this.createOrgConfig(flags, accessToken)
10169
await orgManager.addOrg(orgConfig)
10270

10371
// Set as default if requested
10472
if (flags['set-default']) {
105-
await orgManager.setDefaultOrg(flags.org)
73+
await orgManager.setDefaultOrg(identifier)
10674
}
10775

10876
// Display success message
109-
const action = isUpdate ? 'updated' : 'logged in to'
110-
const aliasText = flags.alias ? ` (${flags.alias})` : ''
111-
this.log(`Successfully ${action} organization '${flags.org}'${aliasText}`)
112-
113-
if (flags['set-default']) {
114-
this.log('Set as default organization.')
115-
}
77+
this.displaySuccessMessage(isUpdate, flags.org, flags.alias, flags['set-default'])
11678

117-
// Return for --json support
11879
return orgConfig
11980
} catch (error) {
120-
// Handle OrgManager errors
121-
if (error instanceof OrgError) {
122-
switch (error.code) {
123-
case OrgErrorCode.FILE_WRITE_ERROR: {
124-
this.error('Failed to save credentials. Please check file permissions.')
125-
break
126-
}
81+
this.handleLoginError(error)
82+
}
83+
}
12784

128-
case OrgErrorCode.INVALID_ACCESS_TOKEN: {
129-
this.error('Invalid access token format. Please check your token and try again.')
130-
break
131-
}
85+
/**
86+
* Creates organization configuration object
87+
*/
88+
private createOrgConfig(
89+
flags: {
90+
alias?: string
91+
'api-version'?: string
92+
'base-url'?: string
93+
org: string
94+
},
95+
accessToken: string,
96+
): OrgConfig {
97+
return {
98+
accessToken,
99+
alias: flags.alias,
100+
apiVersion: flags['api-version'],
101+
baseUrl: flags['base-url'] ?? DEFAULT_BASE_URL,
102+
orgId: flags.org,
103+
}
104+
}
132105

133-
case OrgErrorCode.INVALID_ORG_ID: {
134-
this.error('Invalid organization ID format. Please check and try again.')
135-
break
136-
}
106+
/**
107+
* Displays success message after login
108+
*/
109+
private displaySuccessMessage(isUpdate: boolean, orgId: string, alias?: string, setDefault?: boolean): void {
110+
const action = isUpdate ? 'updated' : 'logged in to'
111+
const aliasText = alias ? ` (${alias})` : ''
112+
this.log(`Successfully ${action} organization '${orgId}'${aliasText}`)
137113

138-
default: {
139-
this.error(`Login failed: ${error.message}`)
140-
break
141-
}
114+
if (setDefault) {
115+
this.log('Set as default organization.')
116+
}
117+
}
118+
119+
/**
120+
* Checks if entry exists and handles overwrite confirmation
121+
*/
122+
private async handleExistingEntry(orgManager: OrgManager, identifier: string, alias?: string): Promise<boolean> {
123+
const exists = await orgManager.orgExists(identifier)
124+
if (!exists) {
125+
return false
126+
}
127+
128+
const identifierType = alias ? 'alias' : 'organization'
129+
const identifierDisplay = alias ? `alias '${alias}'` : `organization '${identifier}'`
130+
131+
const shouldOverwrite = await this.promptForConfirmation(
132+
`${identifierType === 'alias' ? 'An entry with' : 'Organization'} ${identifierDisplay} already exists. Do you want to overwrite the access token?`,
133+
)
134+
135+
if (!shouldOverwrite) {
136+
this.log('Login cancelled.')
137+
this.exit(0)
138+
}
139+
140+
await orgManager.removeOrg(identifier)
141+
return true
142+
}
143+
144+
/**
145+
* Handles errors during login process
146+
*/
147+
private handleLoginError(error: unknown): never {
148+
if (error instanceof OrgError) {
149+
switch (error.code) {
150+
case OrgErrorCode.FILE_WRITE_ERROR: {
151+
this.error('Failed to save credentials. Please check file permissions.')
152+
break
153+
}
154+
155+
case OrgErrorCode.INVALID_ACCESS_TOKEN: {
156+
this.error('Invalid access token format. Please check your token and try again.')
157+
break
158+
}
159+
160+
case OrgErrorCode.INVALID_ORG_ID: {
161+
this.error('Invalid organization ID format. Please check and try again.')
162+
break
163+
}
164+
165+
default: {
166+
this.error(`Login failed: ${error.message}`)
167+
break
142168
}
143169
}
170+
}
144171

145-
// Handle user cancellation
146-
if (error instanceof Error && error.message === 'Cancelled by user') {
147-
this.error('Login cancelled.')
172+
if (error instanceof Error && error.message === 'Cancelled by user') {
173+
this.error('Login cancelled.')
174+
}
175+
176+
// Re-throw unexpected errors
177+
throw error
178+
}
179+
180+
/**
181+
* Obtains access token from environment or user prompt
182+
*/
183+
private async obtainAccessToken(noPrompt: boolean): Promise<string> {
184+
if (noPrompt) {
185+
const accessToken = process.env.SC_ACCESS_TOKEN || ''
186+
if (!accessToken) {
187+
this.error('SC_ACCESS_TOKEN environment variable is not set. Please set it or remove the --no-prompt flag.')
148188
}
149189

150-
// Re-throw unexpected errors
151-
throw error
190+
return accessToken
152191
}
192+
193+
return this.promptForToken()
153194
}
154195

155196
/**

test/commands/account/login.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,11 +55,36 @@ describe('account:login', () => {
5555
const {stdout} = await runCommand(`account:login --org=${testOrg} --alias=${testAlias} --no-prompt`)
5656

5757
// Assert
58+
expect(orgManagerStub.orgExists.calledWith(testAlias)).to.be.true
5859
const addOrgCall = orgManagerStub.addOrg.getCall(0).args[0] as OrgConfig
5960
expect(addOrgCall.alias).to.equal(testAlias)
6061
expect(stdout).to.contain(`(${testAlias})`)
6162
})
6263

64+
it('allows multiple logins for same org with different aliases --no-prompt', async () => {
65+
// Arrange
66+
const testOrg = 'test-org-multi'
67+
const testToken = 'test-token'
68+
process.env.SC_ACCESS_TOKEN = testToken
69+
70+
// First login with alias 'production'
71+
orgManagerStub.orgExists.withArgs('production').resolves(false)
72+
orgManagerStub.addOrg.resolves()
73+
74+
await runCommand(`account:login --org=${testOrg} --alias=production --no-prompt`)
75+
76+
// Second login with alias 'staging' (same org, different alias)
77+
orgManagerStub.orgExists.withArgs('staging').resolves(false)
78+
79+
// Act
80+
const {stdout} = await runCommand(`account:login --org=${testOrg} --alias=staging --no-prompt`)
81+
82+
// Assert
83+
expect(orgManagerStub.orgExists.calledWith('staging')).to.be.true
84+
expect(orgManagerStub.addOrg.calledTwice).to.be.true
85+
expect(stdout).to.contain('Successfully logged in')
86+
})
87+
6388
it('runs account:login --org=test-org --set-default --no-prompt', async () => {
6489
// Arrange
6590
const testOrg = 'test-org-default'
@@ -78,6 +103,24 @@ describe('account:login', () => {
78103
expect(stdout).to.contain('Set as default organization')
79104
})
80105

106+
it('sets default using alias when both --alias and --set-default are provided --no-prompt', async () => {
107+
// Arrange
108+
const testOrg = 'test-org-default'
109+
const testAlias = 'production'
110+
const testToken = 'test-token'
111+
process.env.SC_ACCESS_TOKEN = testToken
112+
113+
orgManagerStub.orgExists.resolves(false)
114+
orgManagerStub.addOrg.resolves()
115+
orgManagerStub.setDefaultOrg.resolves()
116+
117+
// Act
118+
await runCommand(`account:login --org=${testOrg} --alias=${testAlias} --set-default --no-prompt`)
119+
120+
// Assert
121+
expect(orgManagerStub.setDefaultOrg.calledWith(testAlias)).to.be.true
122+
})
123+
81124
it('runs account:login with custom base-url and api-version --no-prompt', async () => {
82125
// Arrange
83126
const testOrg = 'test-org-custom'
@@ -124,6 +167,33 @@ describe('account:login', () => {
124167
confirmStub.restore()
125168
})
126169

170+
it('overwrites existing alias when user confirms --no-prompt', async () => {
171+
// Arrange
172+
const testOrg = 'test-org'
173+
const testAlias = 'existing-alias'
174+
const testToken = 'new-token'
175+
process.env.SC_ACCESS_TOKEN = testToken
176+
177+
orgManagerStub.orgExists.resolves(true)
178+
orgManagerStub.removeOrg.resolves()
179+
orgManagerStub.addOrg.resolves()
180+
181+
// Stub confirmation to auto-accept
182+
const confirmStub = sinon.stub(AccountLogin.prototype as unknown as Record<string, unknown>, 'promptForConfirmation').resolves(true)
183+
184+
// Act
185+
const {stdout} = await runCommand(`account:login --org=${testOrg} --alias=${testAlias} --no-prompt`)
186+
187+
// Assert
188+
expect(confirmStub.calledOnce).to.be.true
189+
expect(orgManagerStub.removeOrg.calledWith(testAlias)).to.be.true
190+
expect(orgManagerStub.addOrg.calledOnce).to.be.true
191+
expect(stdout).to.contain('Successfully updated organization')
192+
193+
// Cleanup
194+
confirmStub.restore()
195+
})
196+
127197
it('cancels login when user declines overwrite', async () => {
128198
// Arrange
129199
const testOrg = 'existing-org'

0 commit comments

Comments
 (0)