Skip to content

Commit a0c39b4

Browse files
feat(cli): add upgrade paths, locations, and billing links to account plan [AI-72] (#1264)
* feat(cli): add upgrade path, locations, and billing link to account plan formatters Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat(cli): add --disabled flag, billing link, and upgradeUrl to account plan Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * test(cli): add e2e tests for upgrade paths and --disabled flag Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * refactor(cli): extract shared upgradeColumn and getAccountAppUrl - Deduplicate REQUIRED UPGRADE column definition into a shared `upgradeColumn` constant used by all three table types - Extract `getAccountAppUrl()` to rest/api.ts as a shared utility for constructing account-scoped app URLs (same base URL pattern as getTestSessionUrl in reporters/util.ts) Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(cli): widen REQUIRED UPGRADE column to prevent text overflow * feat(cli): show Contact sales for disabled entitlements without upgrade path Disabled entitlements with no requiredPlan/requiredAddon now show "Contact sales" in the REQUIRED UPGRADE column and link to checklyhq.com/contact-sales instead of the billing checkout page. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat(cli): show both self-service and enterprise upgrade links Plan header now shows both links: - Self-service upgrade: billing checkout URL - For Enterprise: checklyhq.com/contact-sales/ Detail view uses contact sales URL for CONTRACT entitlements. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat(cli): add color styling and use production app URL - Cyan bold labels for Plan, Locations, Add-ons, Metered entitlements - Magenta highlight on full rows for disabled entitlements in tables - Green/magenta bold counts in the summary line - Dim location names for subtlety - Hardcode production URL (https://app.checklyhq.com) instead of deriving from API base URL - Remove unused getAccountAppUrl from api.ts Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(cli): skip row highlighting when all rows are disabled * feat(cli): add per-entitlement upgradeUrl to JSON output Each disabled entitlement now includes an upgradeUrl field in JSON: - Self-serve features: billing checkout URL - Enterprise/CONTRACT features: contact sales URL - Enabled features: no upgradeUrl (clean) Top-level JSON also includes both upgradeUrl and contactSalesUrl. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * refactor(cli): unify upgrade URL routing and constants - Extract shared getEntitlementUpgradeUrl() from formatter, used by both detail view and JSON enrichment (was duplicated) - Export CONTACT_SALES_URL from formatter, remove duplicate in command - Rename top-level JSON key from upgradeUrl to checkoutUrl for clarity - Simplify withUpgradeUrl to delegate to shared function Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
1 parent f62ffa6 commit a0c39b4

File tree

6 files changed

+603
-30
lines changed

6 files changed

+603
-30
lines changed

packages/cli/e2e/__tests__/account-plan.spec.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,4 +120,67 @@ describe('checkly account plan', () => {
120120
})
121121
expect(result.status).not.toBe(0)
122122
})
123+
124+
it('should show REQUIRED UPGRADE column in summary view', async () => {
125+
const result = await runChecklyCli({
126+
args: ['account', 'plan'],
127+
apiKey: config.get('apiKey'),
128+
accountId: config.get('accountId'),
129+
})
130+
expect(result.status).toBe(0)
131+
expect(result.stdout).toContain('REQUIRED UPGRADE')
132+
})
133+
134+
it('should show billing checkout link', async () => {
135+
const result = await runChecklyCli({
136+
args: ['account', 'plan'],
137+
apiKey: config.get('apiKey'),
138+
accountId: config.get('accountId'),
139+
})
140+
expect(result.status).toBe(0)
141+
expect(result.stdout).toContain('billing/checkout')
142+
})
143+
144+
it('should include checkoutUrl and contactSalesUrl in JSON output', async () => {
145+
const result = await runChecklyCli({
146+
args: ['account', 'plan', '--output', 'json'],
147+
apiKey: config.get('apiKey'),
148+
accountId: config.get('accountId'),
149+
})
150+
expect(result.status).toBe(0)
151+
const parsed = JSON.parse(result.stdout)
152+
expect(parsed).toHaveProperty('checkoutUrl')
153+
expect(parsed.checkoutUrl).toContain('billing/checkout')
154+
expect(parsed).toHaveProperty('contactSalesUrl')
155+
expect(parsed.contactSalesUrl).toContain('contact-sales')
156+
})
157+
158+
it('should filter with --disabled flag', async () => {
159+
const result = await runChecklyCli({
160+
args: ['account', 'plan', '--disabled'],
161+
apiKey: config.get('apiKey'),
162+
accountId: config.get('accountId'),
163+
})
164+
expect(result.status).toBe(0)
165+
expect(result.stdout).toContain('entitlement')
166+
})
167+
168+
it('should combine --disabled with --type', async () => {
169+
const result = await runChecklyCli({
170+
args: ['account', 'plan', '--disabled', '--type', 'flag'],
171+
apiKey: config.get('apiKey'),
172+
accountId: config.get('accountId'),
173+
})
174+
expect(result.status).toBe(0)
175+
expect(result.stdout).toContain('REQUIRED UPGRADE')
176+
})
177+
178+
it('should fail when combining key with --disabled', async () => {
179+
const result = await runChecklyCli({
180+
args: ['account', 'plan', 'BROWSER_CHECKS', '--disabled'],
181+
apiKey: config.get('apiKey'),
182+
accountId: config.get('accountId'),
183+
})
184+
expect(result.status).not.toBe(0)
185+
})
123186
})

packages/cli/src/commands/account/plan.ts

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,15 @@ import {
77
formatPlanSummary,
88
formatEntitlementDetail,
99
formatFilteredEntitlements,
10+
getEntitlementUpgradeUrl,
11+
CONTACT_SALES_URL,
1012
} from '../../formatters/account-plan'
13+
import type { Entitlement } from '../../rest/entitlements'
14+
15+
function withUpgradeUrl (e: Entitlement, checkoutUrl: string) {
16+
if (e.enabled) return e
17+
return { ...e, upgradeUrl: getEntitlementUpgradeUrl(e, checkoutUrl) }
18+
}
1119

1220
export default class AccountPlan extends AuthCommand {
1321
static coreCommand = false
@@ -33,16 +41,20 @@ export default class AccountPlan extends AuthCommand {
3341
char: 's',
3442
description: 'Search entitlements by name or description.',
3543
}),
44+
disabled: Flags.boolean({
45+
description: 'Show only entitlements not included in your plan.',
46+
default: false,
47+
}),
3648
output: outputFlag({ default: 'table' }),
3749
}
3850

3951
async run (): Promise<void> {
4052
const { args, flags } = await this.parse(AccountPlan)
4153
this.style.outputFormat = flags.output
4254

43-
// Validate: key arg is mutually exclusive with --type and --search
44-
if (args.key && (flags.type || flags.search)) {
45-
this.error('Cannot use --type or --search when looking up a specific entitlement key.')
55+
// Validate: key arg is mutually exclusive with --type, --search, and --disabled
56+
if (args.key && (flags.type || flags.search || flags.disabled)) {
57+
this.error('Cannot use --type, --search, or --disabled when looking up a specific entitlement key.')
4658
}
4759

4860
let plan
@@ -55,6 +67,9 @@ export default class AccountPlan extends AuthCommand {
5567
return
5668
}
5769

70+
const { accountId } = api.getDefaults()
71+
const checkoutUrl = `https://app.checklyhq.com/accounts/${accountId}/billing/checkout`
72+
5873
// Single key lookup
5974
if (args.key) {
6075
const entitlement = plan.entitlements.find(e => e.key === args.key)
@@ -63,19 +78,23 @@ export default class AccountPlan extends AuthCommand {
6378
}
6479

6580
if (flags.output === 'json') {
66-
this.log(JSON.stringify(entitlement, null, 2))
81+
this.log(JSON.stringify(withUpgradeUrl(entitlement, checkoutUrl), null, 2))
6782
return
6883
}
6984

7085
const fmt: OutputFormat = flags.output === 'md' ? 'md' : 'terminal'
71-
this.log(formatEntitlementDetail(plan, entitlement, fmt))
86+
this.log(formatEntitlementDetail(plan, entitlement, fmt, checkoutUrl))
7287
return
7388
}
7489

75-
// Apply filters (--type and --search)
76-
const hasFilters = flags.type || flags.search
90+
// Apply filters (--type, --search, --disabled)
91+
const hasFilters = flags.type || flags.search || flags.disabled
7792
let filtered = plan.entitlements
7893

94+
if (flags.disabled) {
95+
filtered = filtered.filter(e => !e.enabled)
96+
}
97+
7998
if (flags.type) {
8099
filtered = filtered.filter(e => e.type === flags.type)
81100
}
@@ -91,19 +110,29 @@ export default class AccountPlan extends AuthCommand {
91110

92111
// JSON output (respects filters)
93112
if (flags.output === 'json') {
94-
this.log(JSON.stringify(hasFilters ? filtered : plan, null, 2))
113+
const enriched = filtered.map(e => withUpgradeUrl(e, checkoutUrl))
114+
if (hasFilters) {
115+
this.log(JSON.stringify(enriched, null, 2))
116+
} else {
117+
this.log(JSON.stringify({
118+
...plan,
119+
checkoutUrl,
120+
contactSalesUrl: CONTACT_SALES_URL,
121+
entitlements: enriched,
122+
}, null, 2))
123+
}
95124
return
96125
}
97126

98127
const fmt: OutputFormat = flags.output === 'md' ? 'md' : 'terminal'
99128

100129
// Filtered view
101130
if (hasFilters) {
102-
this.log(formatFilteredEntitlements(plan, filtered, fmt))
131+
this.log(formatFilteredEntitlements(plan, filtered, fmt, checkoutUrl))
103132
return
104133
}
105134

106135
// Default summary view
107-
this.log(formatPlanSummary(plan, fmt))
136+
this.log(formatPlanSummary(plan, fmt, checkoutUrl))
108137
}
109138
}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import type { Entitlement, AccountPlan } from '../../../rest/entitlements'
2+
3+
// --- Entitlements ---
4+
5+
export const enabledMetered: Entitlement = {
6+
key: 'BROWSER_CHECKS',
7+
name: 'Browser checks',
8+
description: 'Maximum number of browser checks',
9+
type: 'metered',
10+
enabled: true,
11+
quantity: 10,
12+
}
13+
14+
export const disabledMeteredWithUpgrade: Entitlement = {
15+
key: 'PRIVATE_LOCATIONS',
16+
name: 'Private locations',
17+
description: 'Maximum number of private locations',
18+
type: 'metered',
19+
enabled: false,
20+
requiredPlan: 'TEAM',
21+
requiredPlanDisplayName: 'Team',
22+
}
23+
24+
export const enabledFlag: Entitlement = {
25+
key: 'SMS_ALERTS',
26+
name: 'SMS alerts',
27+
description: 'Receive alert notifications via SMS',
28+
type: 'flag',
29+
enabled: true,
30+
}
31+
32+
export const disabledFlagPlanOnly: Entitlement = {
33+
key: 'ADVANCED_ALERT_CHANNELS',
34+
name: 'Advanced alert channels',
35+
description: 'Alert channels like OpsGenie, PagerDuty',
36+
type: 'flag',
37+
enabled: false,
38+
requiredPlan: 'TEAM',
39+
requiredPlanDisplayName: 'Team',
40+
}
41+
42+
export const disabledFlagAddonOnly: Entitlement = {
43+
key: 'STATUS_PAGES_BULK_SUBSCRIBE',
44+
name: 'Status page bulk subscribe',
45+
description: 'Bulk subscribe contacts to status page notifications',
46+
type: 'flag',
47+
enabled: false,
48+
requiredAddon: {
49+
name: 'communicate',
50+
displayName: 'Communicate',
51+
tier: 'TIER_STARTER',
52+
tierDisplayName: 'Starter',
53+
},
54+
}
55+
56+
export const disabledFlagBothPlanAndAddon: Entitlement = {
57+
key: 'STATUS_PAGES_SLACK_NOTIFICATIONS',
58+
name: 'Status page Slack notifications',
59+
description: 'Send status page updates to Slack channels',
60+
type: 'flag',
61+
enabled: false,
62+
requiredPlan: 'STARTER',
63+
requiredPlanDisplayName: 'Starter',
64+
requiredAddon: {
65+
name: 'communicate',
66+
displayName: 'Communicate',
67+
tier: 'TIER_STARTER',
68+
tierDisplayName: 'Starter',
69+
},
70+
}
71+
72+
export const disabledFlagNoUpgradeData: Entitlement = {
73+
key: 'DATACENTER_BARE_METAL',
74+
name: 'Bare metal data centers',
75+
description: 'Run checks from dedicated bare metal infrastructure',
76+
type: 'flag',
77+
enabled: false,
78+
}
79+
80+
// --- Account Plans ---
81+
82+
export const hobbyPlan: AccountPlan = {
83+
plan: 'hobby',
84+
planDisplayName: 'Hobby',
85+
addons: {},
86+
locations: {
87+
all: [
88+
{ id: 'us-east-1', name: 'N. Virginia', available: true },
89+
{ id: 'us-east-2', name: 'Ohio', available: false },
90+
{ id: 'eu-central-1', name: 'Frankfurt', available: true },
91+
{ id: 'eu-west-1', name: 'Ireland', available: false },
92+
{ id: 'ap-southeast-1', name: 'Singapore', available: true },
93+
],
94+
maxPerCheck: 3,
95+
},
96+
entitlements: [
97+
enabledMetered,
98+
disabledMeteredWithUpgrade,
99+
enabledFlag,
100+
disabledFlagPlanOnly,
101+
disabledFlagAddonOnly,
102+
disabledFlagBothPlanAndAddon,
103+
disabledFlagNoUpgradeData,
104+
],
105+
}
106+
107+
export const teamPlan: AccountPlan = {
108+
plan: 'team',
109+
planDisplayName: 'Team',
110+
addons: {
111+
communicate: { tier: 'TIER_STARTER', tierDisplayName: 'Communicate Starter' },
112+
},
113+
locations: {
114+
all: [
115+
{ id: 'us-east-1', name: 'N. Virginia', available: true },
116+
{ id: 'eu-central-1', name: 'Frankfurt', available: true },
117+
],
118+
maxPerCheck: 6,
119+
},
120+
entitlements: [
121+
{ ...enabledMetered, quantity: 50 },
122+
{
123+
...disabledMeteredWithUpgrade,
124+
enabled: true,
125+
quantity: 10,
126+
requiredPlan: undefined,
127+
requiredPlanDisplayName: undefined,
128+
},
129+
enabledFlag,
130+
{ ...disabledFlagPlanOnly, enabled: true, requiredPlan: undefined, requiredPlanDisplayName: undefined },
131+
],
132+
}
133+
134+
export const planWithoutLocations: AccountPlan = {
135+
plan: 'hobby',
136+
planDisplayName: 'Hobby',
137+
addons: {},
138+
entitlements: [enabledMetered, disabledFlagPlanOnly],
139+
}

0 commit comments

Comments
 (0)