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
44 changes: 43 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ npm link
export JIRA_API_TOKEN=your-api-token
export JIRA_USERNAME=your-email@company.com
export JIRA_API_VERSION=auto # optional: auto (default), 2, 3

# Scoped API token (Atlassian Cloud) β€” also set Cloud ID
export JIRA_CLOUD_ID=your-cloud-id # routes through Atlassian Platform API Gateway
```

4. **Verify connection:**
Expand All @@ -82,10 +85,11 @@ npm link

## Configuration

JIRA CLI supports two authentication modes:
JIRA CLI supports the following authentication modes:

1. **Bearer Token Authentication (Recommended)** - Uses API token directly
2. **Basic Authentication** - Uses username + API token (legacy)
3. **Scoped API Token (Atlassian Cloud)** - Basic auth + Cloud ID, routed through the Atlassian Platform API Gateway. Use this for Atlassian's newer scoped API tokens.

### Option 1: Command Line Configuration

Expand All @@ -99,10 +103,17 @@ jira config --server https://yourcompany.atlassian.net \
--username your-email@company.com \
--token your-api-token

# Scoped API token (Atlassian Cloud) β€” set Cloud ID to route via the Platform API Gateway
jira config --server https://yourcompany.atlassian.net \
--username your-email@company.com \
--token your-scoped-api-token \
--cloud-id your-cloud-id

# Or set individual values
jira config set server https://yourcompany.atlassian.net
jira config set token your-api-token
jira config set username your-email@company.com # optional
jira config set cloudId your-cloud-id # optional, enables scoped tokens
jira config set apiVersion auto # optional: auto (default), 2, 3

# Show current configuration
Expand Down Expand Up @@ -143,13 +154,44 @@ export JIRA_API_TOKEN="your-api-token"
export JIRA_API_VERSION="auto" # optional: auto (default), 2, 3
```

### Scoped API Tokens (Atlassian Cloud)

Atlassian recommends using **scoped API tokens** over classic (unscoped) tokens. Scoped tokens require requests to go through the **Atlassian Platform API Gateway** at `https://api.atlassian.com/ex/jira/{cloudId}` instead of `https://{your-site}.atlassian.net`.

To use a scoped token, configure your **Cloud ID** in addition to the usual server, username, and token:

```bash
# Via flags
jira config --server https://yourcompany.atlassian.net \
--username your-email@company.com \
--token your-scoped-api-token \
--cloud-id your-cloud-id

# Or via env vars (works with both JIRA_HOST and JIRA_DOMAIN formats)
export JIRA_CLOUD_ID="your-cloud-id"
```

When `cloudId` is set, all REST API and Agile API requests are automatically routed through the Platform API Gateway. Without it, requests use the configured server URL directly (compatible with classic tokens, Jira Data Center, and Jira Server).

#### Finding Your Cloud ID

You can fetch your Cloud ID without authentication by visiting (or `curl`-ing):

```
https://your-site.atlassian.net/_edge/tenant_info
```

The response contains a `cloudId` field. Copy it into `--cloud-id` or `JIRA_CLOUD_ID`.

### Getting Your API Token

1. Go to [Atlassian Account Settings](https://id.atlassian.com/manage-profile/security/api-tokens)
2. Click "Create API token"
3. Give it a label (e.g., "jira-cli")
4. Copy the generated token

For **scoped tokens**, you can choose specific Jira scopes (e.g., `read:jira-work`, `write:jira-work`) when creating the token. Scoped tokens require Cloud ID configuration β€” see [Scoped API Tokens (Atlassian Cloud)](#scoped-api-tokens-atlassian-cloud).

## Usage

### Read an Issue
Expand Down
24 changes: 17 additions & 7 deletions bin/commands/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ function createConfigCommand(factory) {
.option('--server <url>', 'set JIRA server URL')
.option('--username <username>', 'set username')
.option('--token <token>', 'set API token')
.option('--cloud-id <cloudId>', 'set Atlassian Cloud ID for scoped API tokens')
.action(async (options) => {
const io = factory.getIOStreams();
const config = factory.getConfig();
Expand All @@ -22,23 +23,28 @@ function createConfigCommand(factory) {
return;
}

if (options.server || options.username || options.token) {
if (options.server || options.username || options.token || options.cloudId) {
// Set individual configuration values
if (options.server) {
config.set('server', options.server.replace(/\/$/, ''));
io.success(`Server set to: ${options.server}`);
}

if (options.username) {
config.set('username', options.username);
io.success(`Username set to: ${options.username}`);
}

if (options.token) {
config.set('token', options.token);
io.success('API token updated');
}

if (options.cloudId) {
config.set('cloudId', options.cloudId);
io.success(`Cloud ID set to: ${options.cloudId} (requests will route via Atlassian Platform API Gateway)`);
}

// Test connection if all required fields are present
if (config.isConfigured()) {
io.info('Testing connection...');
Expand All @@ -59,13 +65,17 @@ function createConfigCommand(factory) {
' jira config --server <url> --token <token>\n\n' +
'Basic authentication (optional):\n' +
' jira config --server <url> --username <email> --token <token>\n\n' +
'Scoped API token (Atlassian Cloud, recommended for new tokens):\n' +
' jira config --server <url> --username <email> --token <scoped-token> --cloud-id <cloudId>\n\n' +
'Or set using individual commands:\n' +
' jira config set server <url>\n' +
' jira config set token <token>\n' +
' jira config set username <email> # optional for Basic auth\n\n' +
' jira config set username <email> # optional for Basic auth\n' +
' jira config set cloudId <cloudId> # optional, enables scoped tokens\n\n' +
'Or use environment variables:\n' +
' Bearer auth: export JIRA_HOST=<url> JIRA_API_TOKEN=<token>\n' +
' Basic auth: export JIRA_HOST=<url> JIRA_API_TOKEN=<token> JIRA_USERNAME=<email>'
' Basic auth: export JIRA_HOST=<url> JIRA_API_TOKEN=<token> JIRA_USERNAME=<email>\n' +
' Scoped token: also export JIRA_CLOUD_ID=<cloudId>'
);
}

Expand Down Expand Up @@ -112,7 +122,7 @@ function createConfigCommand(factory) {
io.success(`${key} set successfully`);

// Test connection if setting critical values
if (['server', 'username', 'token'].includes(key) && config.isConfigured()) {
if (['server', 'username', 'token', 'cloudId'].includes(key) && config.isConfigured()) {
io.info('Testing connection...');
const testResult = await config.testConfig();

Expand Down Expand Up @@ -149,7 +159,7 @@ function createConfigCommand(factory) {

function getConfigAction(options) {
if (options.show) return 'show';
if (options.server || options.username || options.token) return 'set';
if (options.server || options.username || options.token || options.cloudId) return 'set';
return 'interactive';
}

Expand Down
27 changes: 25 additions & 2 deletions lib/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ class Config {
token: {
type: 'string'
},
cloudId: {
type: 'string'
},
apiVersion: {
type: 'string',
enum: ['auto', '2', '3'],
Expand Down Expand Up @@ -60,6 +63,8 @@ class Config {

// Get required configuration or throw error
getRequiredConfig() {
const cloudId = process.env.JIRA_CLOUD_ID || this.get('cloudId') || '';

// First try JIRA_HOST environment variables (new format)
if (process.env.JIRA_HOST && process.env.JIRA_API_TOKEN) {
return {
Expand All @@ -68,6 +73,7 @@ class Config {
`https://${process.env.JIRA_HOST}`,
username: process.env.JIRA_USERNAME || '', // Empty username for token-only auth
token: process.env.JIRA_API_TOKEN,
cloudId,
apiVersion: process.env.JIRA_API_VERSION || this.get('apiVersion') || 'auto'
};
}
Expand All @@ -80,6 +86,7 @@ class Config {
`https://${process.env.JIRA_DOMAIN}`,
username: process.env.JIRA_USERNAME,
token: process.env.JIRA_API_TOKEN,
cloudId,
apiVersion: process.env.JIRA_API_VERSION || this.get('apiVersion') || 'auto'
};
}
Expand All @@ -91,16 +98,20 @@ class Config {
' ' + chalk.yellow('jira config --server <url> --token <token>') + '\n' +
'For Basic auth, also provide:\n' +
' ' + chalk.yellow('jira config --username <email>') + '\n' +
'For scoped API tokens (Atlassian Cloud), also provide:\n' +
' ' + chalk.yellow('jira config --cloud-id <cloudId>') + '\n' +
'Or use environment variables:\n' +
' Bearer auth: JIRA_HOST, JIRA_API_TOKEN\n' +
' Basic auth: JIRA_HOST, JIRA_API_TOKEN, JIRA_USERNAME'
' Basic auth: JIRA_HOST, JIRA_API_TOKEN, JIRA_USERNAME\n' +
' Scoped token: add JIRA_CLOUD_ID'
);
}

return {
server: this.get('server'),
username: this.get('username') || '',
token: this.get('token'),
cloudId,
apiVersion: process.env.JIRA_API_VERSION || this.get('apiVersion') || 'auto'
};
}
Expand Down Expand Up @@ -145,20 +156,32 @@ class Config {
console.log('Server:', chalk.green(process.env.JIRA_HOST));
console.log('Username:', chalk.green(process.env.JIRA_USERNAME || '(token auth)'));
console.log('Token:', chalk.green('***configured***'));
if (process.env.JIRA_CLOUD_ID) {
console.log('Cloud ID:', chalk.green(process.env.JIRA_CLOUD_ID));
console.log('Routing:', chalk.green('Atlassian Platform API Gateway (scoped token)'));
}
console.log('API Version:', chalk.green(process.env.JIRA_API_VERSION || 'auto'));
} else if (process.env.JIRA_DOMAIN) {
console.log('Server:', chalk.green(process.env.JIRA_DOMAIN));
console.log('Username:', chalk.green(process.env.JIRA_USERNAME));
console.log('Token:', chalk.green('***configured***'));
if (process.env.JIRA_CLOUD_ID) {
console.log('Cloud ID:', chalk.green(process.env.JIRA_CLOUD_ID));
console.log('Routing:', chalk.green('Atlassian Platform API Gateway (scoped token)'));
}
console.log('API Version:', chalk.green(process.env.JIRA_API_VERSION || 'auto'));
}
}

if (Object.keys(config).length > 0) {
console.log(chalk.blue('\nFrom Config File:'));
console.log('Server:', chalk.green(config.server || 'Not set'));
console.log('Username:', chalk.green(config.username || '(Bearer auth)'));
console.log('Token:', config.token ? chalk.green('Set (hidden)') : chalk.red('Not set'));
if (config.cloudId) {
console.log('Cloud ID:', chalk.green(config.cloudId));
console.log('Routing:', chalk.green('Atlassian Platform API Gateway (scoped token)'));
}
console.log('API Version:', chalk.green(config.apiVersion || 'auto'));
}

Expand Down
28 changes: 25 additions & 3 deletions lib/jira-client.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
const axios = require('axios');

const ATLASSIAN_GATEWAY_HOST = 'https://api.atlassian.com';

class JiraClient {
constructor(config) {
this.config = config;
this.baseURL = config.server;
this.cloudId = (config.cloudId || '').trim();
this.useGateway = this.cloudId.length > 0;
this.gatewayBase = this.useGateway
? `${ATLASSIAN_GATEWAY_HOST}/ex/jira/${this.cloudId}`
: null;
this.apiVersionMode = this.normalizeApiVersionMode(config.apiVersion || process.env.JIRA_API_VERSION);
this.apiVersion = this.apiVersionMode === 'auto' ? 3 : this.apiVersionMode;
this.axiosConfigKeys = new Set([
Expand Down Expand Up @@ -239,9 +246,14 @@ class JiraClient {
return 'auto';
}

buildApiBaseUrl(suffix) {
const root = this.useGateway ? this.gatewayBase : this.baseURL;
return `${root}${suffix}`;
}

createApiClient(version, { auth, headers }) {
const client = axios.create({
baseURL: `${this.baseURL}/rest/api/${version}`,
baseURL: this.buildApiBaseUrl(`/rest/api/${version}`),
auth,
headers
});
Expand All @@ -254,7 +266,7 @@ class JiraClient {

createAgileClient({ auth, headers }) {
const client = axios.create({
baseURL: `${this.baseURL}/rest/agile/1.0`,
baseURL: this.buildApiBaseUrl('/rest/agile/1.0'),
auth,
headers
});
Expand Down Expand Up @@ -287,7 +299,17 @@ class JiraClient {
}

formatJiraErrorMessage(status, data) {
if (status === 401) return 'Authentication failed. Please check your credentials.';
if (status === 401) {
const base = 'Authentication failed. Please check your credentials.';
if (this.useGateway) {
return base +
'\nUsing scoped API token via Atlassian Platform API Gateway.' +
'\n - Verify the token has the required Jira scopes (e.g., read:jira-work, write:jira-work)' +
'\n - Verify your Cloud ID is correct' +
'\n - Verify the email matches the account that created the token';
}
return base;
}
if (status === 403) return 'Access denied. You don\'t have permission to perform this action.';
if (status === 404) return 'Resource not found.';
return data?.errorMessages ? data.errorMessages.join(', ') : 'API request failed';
Expand Down
30 changes: 30 additions & 0 deletions tests/commands/config.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,11 @@ describe('ConfigCommand', () => {
const tokenOption = configCommand.options.find(opt => opt.long === '--token');
expect(tokenOption).toBeDefined();
});

it('should have cloud-id option', () => {
const cloudIdOption = configCommand.options.find(opt => opt.long === '--cloud-id');
expect(cloudIdOption).toBeDefined();
});
});

describe('Bearer authentication support', () => {
Expand Down Expand Up @@ -114,4 +119,29 @@ describe('ConfigCommand', () => {
expect(mockConfig.testConfig).toHaveBeenCalled();
});
});

describe('scoped API token (--cloud-id) support', () => {
it('should set cloudId when --cloud-id is provided', async () => {
mockConfig.isConfigured.mockReturnValue(false);

await configCommand.parseAsync(['node', 'test',
'--server', 'https://test.atlassian.net',
'--username', 'test@example.com',
'--token', 'scoped-token',
'--cloud-id', 'abcd-1234'
]);

expect(mockConfig.set).toHaveBeenCalledWith('cloudId', 'abcd-1234');
});

it('should accept --cloud-id alone without other flags', async () => {
mockConfig.isConfigured.mockReturnValue(false);

await configCommand.parseAsync(['node', 'test',
'--cloud-id', 'abcd-1234'
]);

expect(mockConfig.set).toHaveBeenCalledWith('cloudId', 'abcd-1234');
});
});
});
Loading
Loading